Message
Displays a message in a conversation, with optional avatar, header, footer, and alignment.
The Message component lays out a single message in a conversation. It handles the avatar, alignment, header, and footer around the message surface.
For AI apps, you can render reasoning steps, tool calls and assistant messages using the Message component.
Installation
import {
Message,
MessageGroup,
} from "@o/pipeline/experimental/components/chat/message";
Usage
import { Avatar } from "@o/pipeline/experimental/components/avatar";
import { Bubble } from "@o/pipeline/experimental/components/chat/bubble";
import { Message } from "@o/pipeline/experimental/components/chat/message";
<Message.Root>
<Message.Avatar>
<Avatar.Root>
<Avatar.Image src="https://github.com/shadcn.png" alt="@shadcn" />
<Avatar.Fallback>CN</Avatar.Fallback>
</Avatar.Root>
</Message.Avatar>
<Message.Content>
<Bubble.Root>
<Bubble.Content>How can I help you today?</Bubble.Content>
</Bubble.Root>
</Message.Content>
</Message.Root>
Note: Message owns the row layout—avatar, alignment, header, and footer.
Render the visible message surface inside it with
Bubble. For the scroll container around a
conversation, use MessageScroller.
Composition
Use the following composition to build a message:
Message.Root
├── Message.Avatar
└── Message.Content
├── Message.Header
│ └── Message.Author
├── Message.Context
│ └── Message.ContextLink
├── Bubble.Root
│ └── Bubble.Content
├── Message.Footer
│ ├── Message.Status
│ ├── Message.Reaction
│ └── Message.Actions
│ ├── Message.Action
│ └── Message.Time
└── Message.Thread
├── Message.ThreadTrigger
└── Message.Actions (when the message has no reactions)
Message.Actions is display: contents, so its children are the footer’s own flex items and share the footer’s row, gap and wrapping. It names a region; it does not draw a box.
The toolbar floated over the bubble’s top-right corner for a while. That bought a row costing no layout height and paid for it everywhere else: it covered the sender’s name, it landed on the bubble above inside a group, it reached past the left edge of any bubble narrower than itself, and on a touch screen it had nowhere to go at all. In the footer it collides with nothing.
Message.Button
A control that sits in the conversation rather than asking for attention: a disclosure summarising work, or a quiet action. It is a Button that defaults to variant="ghost", and as a ghost it reads a step quieter than Button, with gray-600 text and gray-500 icons and caret. Any other variant renders a plain Button.
Pass it to Collapsible.Trigger to make a quiet disclosure:
<Collapsible.Trigger bleed="start" render={<Message.Button />}>
5 background runs
</Collapsible.Trigger>
Use MessageGroup to stack consecutive messages from the same sender:
MessageGroup
├── Message.Root
└── Message.Root
Features
- Start and end alignment for sender and receiver rows via the
alignprop - Avatar slot that anchors to the top of the message
- Header and footer slots for sender names, status, and message actions
- Footer follows the message side, and mirrors its order on
align="end"rows so hovering moves nothing already shown - Group wrapper for stacking consecutive messages from the same sender
- Customizable styling through the
classNameprop on every part
Avatar
Use Message.Avatar to render an avatar next to the message. Set align="end" on the message to align the avatar to the end of the message.
Every message carries an avatar and a name, the reader’s own included. An earlier version dropped both from your own messages, on the grounds that a green bubble on the right of the column already said whose they were. Neither of those two things is true any more — every message is start-aligned, and the reader’s own bubble is the plain one rather than a branded colour — so the avatar is what tells one sender from the next.
align="end" still exists and still moves the whole row, avatar included. It is no longer how a transcript says “this one is yours”.
| align | Description |
|---|---|
start | Align the message to the start of the conversation. |
end | Align the message to the end of the conversation. |
Group
Use MessageGroup to stack consecutive messages from the same sender. Render an empty Message.Avatar on the earlier messages to keep them aligned with the avatar on the last one.
Header and Footer
Message.Header is the sender’s name, in Message.Author. It is a fixed 20px — the avatar’s height — and truncates, so a long name cannot change the distance from the avatar to the bubble.
Message.Footer is everything under the message: the reactions it has collected, the actions it offers, and Message.Time. It is at least 40px whether or not it holds anything, and since the feed adds no gap of its own and a message pads itself by nothing, that 40px is the whole distance from one message to the next.
Actions
There are two places for actions, and which one a control belongs in depends on whether it is state or an offer.
Message.Footer is for what is already true of the message — a delivery status, a retry on something that failed, the reactions it has collected. It sits in the flow. Say a status with Message.Status rather than bare text: the part sets its type and colour, and the footer centres it on the controls beside it and keeps it 6px from them.
Message.Actions is for what the message offers you. It goes in the footer beside the reactions, because a reaction someone left and an action you can take are both things this message carries, and keeping them on one line is what stops a message growing two bands.
What separates them is when they are visible rather than where they sit. Everything inside Message.Actions waits to be reached for: it shows on hover or keyboard focus, and on a long-press where there is no hover. What the message has collected reads at rest, so the reactions, and the picker once there are any, sit beside Message.Actions rather than inside it.
A message the reader came to act on, like the one a thread hangs off, can pass visibleOnTouch so its actions read at rest where there is no hover.
Use Button for icon-only actions in either.
The footer runs in one order: what the message has collected, then what it offers, then when it was sent — the reactions, the picker, copy, reply, the menu, and Message.Time last. The menu is a Menu behind a Message.Action ellipsis, for what a message offers beyond one click: copying a link to it for everyone, flagging an agent’s reply, then inspecting its agent run for staff, each group behind a separator. Reactions come first because they are the common case and the cheapest gesture, and the picker sits at the end of them because it is the same gesture one click further.
Under an end-aligned message the footer runs in reverse, by group rather than by control: Message.Time, then the actions, then the reactions with the picker still last among them. A message with no reactions keeps the same order: the quick set and the picker sit where the pills would, between the actions and the thread trigger. The footer sits at the end, so whatever it shows at rest has to hold that edge. In start order the reveal would add its controls on the right and push the pills left, moving the thing you were reaching for as you reached for it. Reversed, the pills stay put and the controls appear in the empty space to their left. The controls inside each group keep their order on both sides, so a message reads the same way whichever side it is on.
Message.Time is at the end and reveals with the controls. It used to sit on the name line, which put a number nobody is reading on the one line that says who is talking, and stated it only on the rows that carry a name — leaving a private chat, and any reply grouped under the one before it, with nowhere to state it at all.
Reactions
Reactions and actions share the footer’s one row. Once a message has a reaction, the quick set stops rendering and the picker stays with the pills: “one more” is what gets reached for next, and it should not move to be found.
A reaction someone else left is a plain subtle pill. The moment the reader reacts, their pill turns green and pressed; taking it back returns it to plain. Click the pills below to try it.
A message pads itself by nothing and the feed adds no gap, so the 40px footer is the entire distance from one message to the next. A run of messages keeps its rhythm whether or not anybody reacted.
A message with no footer reserves the same 40px itself. Otherwise a row that carries only a status — a typing indicator, a run still going — contributes no space at all and collapses against the message under it, while every row around it keeps its rhythm. Message.Root drops the reservation the moment a footer arrives to hold it instead.
A message still being written renders no footer. Its time, its copy and the controls that act on it describe a message that has not landed, so they arrive when it settles. The root’s reservation keeps the row the same height in the meantime, so nothing moves when the footer takes the band over.
Past a single row the footer wraps and grows a row at a time, so only the message that collected the reactions gets taller. A virtualized transcript has to measure rows for that reason whatever the minimum is — the minimum is what keeps every row that has not wrapped identical.
Threads
A message someone has replied to in a thread carries Message.Thread: Message.ThreadTrigger in a footer row of its own, behind a hairline. The trigger is a ghost pill Message.Button with the collapsible caret, rounded like the reactions, showing the faces of the people in the thread and how many replies it holds. It is as wide as its contents and bleeds at the start, so the faces sit on the column’s edge.
Starting a thread and opening one are two different things. Starting one is an offer: “Reply in thread” is a Message.Action in Message.Actions, revealed with copy and the menu. A thread that exists is standing state, so it reads at rest and never waits to be reached for. The toolbar keeps its reply action either way.
Where the actions go depends on whether the message has reactions:
- With reactions, the thread stacks under
Message.Footer. The reactions and actions keep their row, and the trigger has the thread row to itself. - Without reactions,
Message.Threadis the message’s only footer, andMessage.Actionsgoes in it beside the trigger, revealed on hover like anywhere else. The trigger comes first under a start-aligned message and last under an end-aligned one, for the same reason the reactions do: it is what shows at rest, so it holds the edge and the actions reveal on the far side of it. An empty footer above the thread would otherwise stand at rest as a 40px gap, held open for a toolbar nobody has reached for.
The hairline is as wide as the bubble above it rather than the column, on whichever side the bubble sits: a column-wide rule reads as a divider across the whole transcript. It is drawn with CSS anchor positioning between the edges of Bubble.Content, and a browser without it gets a full-width line instead. It is never shorter than the trigger under it: when the bubble is narrower, the line’s far end reaches the trigger’s edge, while its near end stays with the bubble. A ghost bubble fills the column whatever its words, so under one the line runs to the trigger alone. The row itself stays full width, so its contents can run past the line; the actions are hidden until reached for. Under an end-aligned message, the thread row and the footer both sit at the end, under the bubble, in reverse group order.
Message.Thread is a Message.Footer in every respect but its spacing, so its controls wrap, reveal and line up with text exactly as they do there, and the message counts it as its footer. The trigger is as tall as the controls, so revealing them beside it does not change the row’s height. The row is only rendered once a thread exists, so a message with no thread costs no height.
{/* With reactions */}
<Message.Footer>
{/* reactions */}
<Message.Actions>{/* actions */}</Message.Actions>
</Message.Footer>
<Message.Thread>
<Message.ThreadTrigger
label="Open thread, 18 replies"
faces={<AvatarGroup.Root size="sm">{/* … */}</AvatarGroup.Root>}
>
18 replies
</Message.ThreadTrigger>
</Message.Thread>
{/* Without reactions */}
<Message.Thread>
<Message.ThreadTrigger label="Open thread, 1 reply" faces={/* … */}>
1 reply
</Message.ThreadTrigger>
<Message.Actions>{/* actions, revealed on hover */}</Message.Actions>
</Message.Thread>
{/* Without reactions, end-aligned: the trigger holds the edge */}
<Message.Thread>
<Message.Actions>{/* time, then actions, revealed on hover */}</Message.Actions>
<Message.ThreadTrigger label="Open thread, 1 reply" faces={/* … */}>
1 reply
</Message.ThreadTrigger>
</Message.Thread>
The thread opens somewhere else rather than below the message, so the trigger is not a disclosure: it carries no aria-expanded, and label gives it an accessible name that the caret’s own name would otherwise run into.
Attachment
Render file and image attachments inside Message.Content with Attachment.
Breakout
Message.Breakout lets wide content, such as a table, grow past the message column into the space beside it, up to 800px. Its child keeps its own width and always starts at the content’s start edge, growing toward the end. It stops at the viewport’s end gutter, so with no room beside the column it stays in the column.
The width comes from the MessageScroller.Item around it, so outside a transcript it is a plain wrapper. The child owns its overflow: wrap a table in Table.Scroller, or Table.Maximizable to let it open in a dialog. A table inside lays out as it would at 800px and scrolls when there is less room, instead of squeezing its columns.
<Message.Breakout>
<Table.Maximizable>
<Table.Root>…</Table.Root>
</Table.Maximizable>
</Message.Breakout>
Accessibility
Message is a presentational layout wrapper. Accessibility comes from the content you place inside it.
Label icon-only actions
Controls in Message.Footer are usually icon-only, so each one needs an accessible name for assistive tech and a visible one for everyone else. Message.Action takes both from its label: it sets the aria-label and puts the same text in Message.Tooltip, shown on hover and on keyboard focus. Pass tooltip when the two should differ, as on a toggle whose tooltip names the reaction while its label says what pressing it will do. Don’t use title, which the browser shows late, in its own style, and never for a keyboard.
Every Message.Tooltip inside one Message.Root shares the message’s single tooltip, so moving along the footer hands the name from control to control without waiting out the delay again.
<Message.Footer>
<Message.Action label="Copy" icon={<IconCopy />} />
</Message.Footer>
Label a reaction row
Reactions render as a row of emoji. A screen reader reads each glyph with no context, and a counter like +8 is announced as “plus eight”. Group a row that is purely a display as a single image with a descriptive aria-label, so it announces once; role="img" also hides the individual emoji from assistive tech, so no aria-hidden is needed.
<span role="img" aria-label="Reactions: thumbs up, fire, and 8 more">
<span>👍</span>
<span>🔥</span>
<span>+8</span>
</span>
role="img" is only for a display. Descendants of role="img" are presentational, so a control inside one disappears from assistive tech — the moment the row holds a button, including an add-a-reaction button, make it a labelled role="group" instead. The stories on this page all take that form, because a reaction someone can toggle is the common case.
Status updates
For in-progress messages, use a Marker with role="status" so assistive tech announces the update as it appears.
import { Marker } from "@o/pipeline/experimental/components/chat/marker";
import { Spinner } from "@o/pipeline/experimental/components/spinner";
<Message.Root>
<Message.Content>
<Marker.Root role="status">
<Marker.Icon>
<Spinner />
</Marker.Icon>
<Marker.Content>Checking the logs...</Marker.Content>
</Marker.Root>
</Message.Content>
</Message.Root>;
API Reference
Message.Root
The message row wrapper.
| Prop | Type | Default | Description |
|---|---|---|---|
align | "start" | "end" | "start" | The alignment of the message in the conversation. |
onTap | () => void | - | A quick touch tap on the message content, outside its footer and controls. Touch only. |
className | string | - | Additional classes to apply to the row. |
On touch, a long-press opens the message’s Message.ActionMenu where the press landed. The message times that press itself rather than leaving it to Base UI, so a swipe that starts on a message still reaches a Drawer around it. A message with Message.Actions and no menu reveals the actions instead, and they stay up until the next touch outside the message or a tap on it. The same gesture selects text and opens the system callout, so such a message turns both off where the pointer cannot hover; copying goes through a copy action instead. A quick tap on the message content, outside the footer and any link or control, calls onTap: the transcript opens the message’s thread with it. A slower press reads as a long-press let go early and does nothing. It is a shortcut, not the only way in, so whatever it does needs a Message.Action too. With a Message.ActionMenu, the message is its ContextMenu trigger, so a right-click opens the menu at the pointer instead of the browser’s own.
Message.ActionMenu
The message’s context menu, filled with Menu.Items: a long-press opens it on touch and a right-click opens it with a pointer. It opens where the press landed, as every ContextMenu does: clear of the finger after a long-press, at the pointer after a right-click. It takes ContextMenu.Content’s props except anchor, plus optional onOpenChange and onOpenChangeComplete, and onOpenAt, which hands over where it opened for something an item opens afterwards from the same place, like an emoji picker. It renders only inside Message.Root, which owns the menu’s root. Hover still needs Message.Actions for the same actions.
MessageGroup
Groups consecutive messages from the same sender.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Additional classes to apply to the group root. |
Message.Avatar
The avatar slot, aligned to the top of the message.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Additional classes to apply to the avatar slot. |
Message.Content
Wraps the header, message surface, and footer.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Additional classes to apply to the content slot. |
Message.Breakout
Lets wide content grow past the message column. See Breakout.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Additional classes to apply to the wrapper. |
Message.Header
The line above the message, holding Message.Author. A fixed 20px so it matches the avatar beside it, truncating rather than wrapping. Stays aligned to the start regardless of align.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Additional classes to apply to the header. |
Message.Author
The sender’s name. 12px, book, gray-600 — a part of its own rather than bare text, so every header says a name the same way.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Additional classes to apply to the name. |
Message.Context
Where else the message lives, on one line between the header and the body: “Also sent to channel” on a thread reply that was repeated in the room, “Replied to a thread: …” on the room’s copy. 12px, gray-500, a fixed 20px that truncates, so a long snippet never pushes the body down. Follows the message’s align like the rest of Message.Content.
| Prop | Type | Default | Description |
|---|---|---|---|
icon | ReactNode | - | A 14px glyph at the start of the line. |
className | string | - | Additional classes to apply to the line. |
Message.ContextLink
The part of a Message.Context line that goes somewhere, such as the thread or the copy in the room. A <button> styled as text, book and gray-600, underlining on hover, because it sits inside a sentence.
| Prop | Type | Default | Description |
|---|---|---|---|
type | string | "button" | The button’s type. |
className | string | - | Additional classes to apply to the link. |
Message.Time
When the message was sent, at the end of Message.Footer. Renders a <time> with a machine-readable datetime and says “4:44pm” — twelve-hour whatever the reader’s locale would do alone, because a 24-hour reading of it is two numbers with nothing between them. 12px, regular, gray-500: a step quieter than the name. With a value, the full date sits behind it in the message’s tooltip, and the help cursor says so.
| Prop | Type | Default | Description |
|---|---|---|---|
value | Date | number | - | Formatted, and carried as datetime. Omit and pass children to say it another way. |
className | string | - | Additional classes to apply to the time. |
Message.Status
What is true of the message, in Message.Footer: “Read yesterday”, “Failed to send”. 12px, regular, gray-600 — a step louder than the time, because a status is read and a time is only reached for. intent="danger" turns it text-danger for a send that failed. The footer owns where it sits: a line box as tall as a sm control, so its centre matches a button beside it, and 6px from whatever stands next to it.
| Prop | Type | Default | Description |
|---|---|---|---|
intent | "none" | "danger" | "none" | danger for a status that reports a fault. |
className | string | - | Additional classes to apply to the status. |
Message.Tooltip
Names a control, or the time, in the design system’s tooltip on hover and on keyboard focus. Every Message.Tooltip inside one Message.Root shares that message’s single tooltip, so moving along the footer hands the name from one control to the next without waiting again. Outside a Message.Root it falls back to a tooltip of its own.
| Prop | Type | Default | Description |
|---|---|---|---|
content | ReactNode | - | The visible name. Keep the control’s aria-label: that is what is announced. |
children | ReactElement | - | The control being named, rendered as the trigger itself. |
Message.Action
One control in Message.Footer: a quick reaction, the picker, copy, reply, a retry beside a failed send. A ghost sm Button, a circle when it holds one thing, an icon or a glyph, with its label as both the accessible name and the tooltip. An icon paired with content keeps Button’s rectangle. Pass an emoji as icon, so it sits in the same frame as every other glyph in the row. Other Button props pass through, including pressed for a quick reaction the reader has left, and it can sit inside another trigger’s render, as the reaction picker’s does. For a reaction the message has collected, use Message.Reaction.
For a quiet button in the message body, such as a disclosure summarising work, use Message.Button instead.
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | - | The accessible name, and the tooltip unless tooltip is given. |
tooltip | string | label | What the tooltip says, where it should differ from the label. |
shape | string | "circle" | circle unless an icon is paired with content; pass pill for a count. |
variant | string | "ghost" | The footer’s quiet style. solid for a fact that should stay legible at rest. |
Message.Reaction
A reaction the message has collected: its emoji, how many people left it, and whether the reader is one of them. A Message.Action in every respect but its look: a subtle pill, because a reaction is standing state that has to read at rest. The emoji takes the icon slot and the count takes the label, so the pair sits on Button’s icon-and-label spacing. The count is always shown, 1 included, in tabular figures so the pill keeps its width as it changes. Someone else’s reaction is subtle with no intent. The reader’s own is intent="success" and pressed, a green fill with the count in the same gray-900 as any pressed subtle button, and returns to no intent when they take it back. Once a message has reactions, the picker beside them is a subtle Message.Action too: one more reaction, circular because it has no count yet.
| Prop | Type | Default | Description |
|---|---|---|---|
emoji | ReactNode | - | The emoji, as a character or an image. Decoration: label names it. |
count | number | - | How many people left it. |
own | boolean | false | Whether the reader is one of them. Renders the pill success and pressed. |
label | string | - | The accessible name, and the tooltip unless tooltip is given. |
tooltip | string | label | Who reacted, where it should differ from the label. |
Message.Actions
The actions a message offers, in the flow at the end of Message.Footer. display: contents, so its children are the footer’s own flex items and share its row, gap and wrapping.
Every child stays transparent until the message is hovered, long-pressed or holds keyboard focus, and stays up while anything in the message is expanded (an open menu or picker) or carries data-message-actions-hold. Put that attribute on whatever opens after its trigger has closed, like a panel a menu item summons. Hidden controls keep their width, so revealing them cannot wrap the footer. Hover reveals them only where the pointer can hover (can-hover); on touch a long-press on the message reveals them instead (see Message.Root).
| Prop | Type | Default | Description |
|---|---|---|---|
visibleOnTouch | boolean | false | Shows the controls at rest where the pointer cannot hover, for a message to act on. |
className | string | - | Additional classes to apply to the toolbar. |
Message.Thread
A footer row holding Message.ThreadTrigger, behind a hairline. Stacks under Message.Footer when the message has reactions; otherwise it is the message’s only footer and holds Message.Actions beside the trigger. Render it only once the message has a thread.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Additional classes to apply to the row. |
Message.ThreadTrigger
Opens the thread. A ghost sm pill Message.Button with the collapsible caret, as wide as its contents and bleeding at the start. Other Button props pass through.
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | - | The accessible name: what pressing it opens, and how much is there. |
faces | ReactNode | - | Who is in the thread, as an AvatarGroup of sm avatars. |
children | ReactNode | - | The visible text, such as “18 replies”. Truncates. |
Message.Footer
Everything under the message: a status, reactions, actions, and the time. On the message’s side: at the end under an end-aligned message. At least 40px whether or not it holds anything, wrapping a row at a time past that.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Additional classes to apply to the footer. |