ChatBubble
A conversation message — aligned bubble or flat AI response — with consecutive-message grouping, delivery status, reactions, quoted replies, media/voice/file attachments, reasoning steps, citations and a log-semantics transcript.
pnpm add @tessinaui/uiWhen to use
- A conversation shown as messages. One sender per row, aligned by
mine, with a name, avatar and timestamp where they help. - A run of consecutive messages from one sender.
groupedrenders the name and avatar once and joins the corners. - Delivery you need to show.
ChatBubbleMetacarries sending, sent, delivered, read and failed, each as a glyph plus words, with a retry for a failure. - An AI response with reasoning steps, citations, streaming and message actions. The flat
ghostvariant keeps the response full width. - A transcript that must announce itself.
ChatBubbleListis a namedrole="log"that follows new content only while the reader is at the bottom. - Attachments and reactions on a message: quoted replies, images, voice, files, link previews and reaction chips.
When not to use
- To compose a message. This component only displays. Use PromptInput.
- For a comment thread or activity feed. A chat bubble implies a live, ordered conversation. Compose Card and Avatar instead.
- For a transient confirmation. Use a Toast.
- As a typed enum for rich content. Code, charts and tables go inside the body as children; there is no
codeorcardprop, on purpose.
Installation
pnpm add @tessinaui/uiUsage
import {
ChatBubble,
ChatBubbleBody,
ChatBubbleMeta,
ChatBubbleList,
} from "@tessinaui/ui";
<ChatBubbleList label="Conversation">
<ChatBubble senderName="Ada" avatar={<Avatar name="Ada" size="md" />}>
<ChatBubbleBody>The notes are ready.</ChatBubbleBody>
<ChatBubbleMeta timestamp="09:12" />
</ChatBubble>
<ChatBubble mine>
<ChatBubbleBody>Reading them now.</ChatBubbleBody>
<ChatBubbleMeta timestamp="09:14" status="read" />
</ChatBubble>
</ChatBubbleList>The root is the row: it owns alignment, the avatar gutter, the header and
the sr-only sender identity. ChatBubbleBody is the plate. Everything
that sits outside the plate — meta, reactions, actions, sources — is a sibling
of the body, in the order you want it rendered.
mine, not from
A message's side, its default plate and its accessible identity are one
decision, so they ride one boolean. mine also leaves room for a third party:
a group chat has many senders, and from="user | assistant" cannot say who.
Coming from the previous ChatBubble
The previous ChatBubble had a different API and has been removed; this component took over its name. If you used the old one, every prop has a place below. The old @tessinaui/ui/chat-bubble entry now resolves to this component.
Previous ChatBubble | ChatBubble |
|---|---|
role="user" / role="assistant" | mine / no mine |
variant="bubble" / "flat" / "card" | variant="filled" / "ghost" / "outline" ("soft" is new) |
size, rounded, dir | the same props. rounded also takes "xl" |
maxWidth | a shared reading measure; fullWidth lets a message use the whole row |
children | ChatBubbleBody inside the row |
avatar, showAvatar | avatar={<Avatar … />}. TessinaAvatar is gone; compose Avatar |
timestamp, showTimestamp | timestamp on ChatBubbleMeta, beside the body |
status="sending" | status="sending" on ChatBubbleMeta |
status="error" + onRetry | status="failed" + onRetry on ChatBubbleMeta |
status="streaming" | streaming on the row, and ChatBubbleTyping until the first text arrives |
showActions + onCopy | ChatBubbleActions holding a ChatBubbleCopy |
onThumbsUp, onThumbsDown, onRegenerate | one ChatBubbleAction each, with an icon, a required label and, for a thumb that is on, pressed |
intent | none. Put status colour in the words, or give the row a tone |
strings | statusLabels and retryLabel on the meta, strings on the action parts |
StreamingIndicator | ChatBubbleTyping |
a hand-made role="log" wrapper | ChatBubbleList |
An assistant message with actions, before and after:
// Before (previous ChatBubble)
<ChatBubble role="assistant" showActions onCopy={copy} onThumbsUp={like} onRegenerate={again}>
The notes are ready.
</ChatBubble>
// After
<ChatBubble variant="ghost" avatar={<Avatar name="Assistant" size="md" />}>
<ChatBubbleBody>The notes are ready.</ChatBubbleBody>
<ChatBubbleActions>
<ChatBubbleCopy text="The notes are ready." />
<ChatBubbleAction icon={<ThumbsUp />} label="Good response" onClick={like} />
<ChatBubbleAction icon={<RotateCcw />} label="Regenerate response" onClick={again} />
</ChatBubbleActions>
</ChatBubble>A failed send moves from the bubble to its meta:
// Before (previous ChatBubble)
<ChatBubble status="error" onRetry={resend}>Where are the notes?</ChatBubble>
// After
<ChatBubble mine>
<ChatBubbleBody>Where are the notes?</ChatBubbleBody>
<ChatBubbleMeta status="failed" onRetry={resend} />
</ChatBubble>Examples
Default
Variants
filled and soft are the two bubble plates; outline is the bordered
support/email shape; ghost drops the plate entirely and is the AI-response
and team-chat layout. Defaults follow mine: filled for me, soft for them.
All four occupy the same box: every variant carries a border so only its
colour changes, and ghost keeps the same inset — on the content column rather
than the plate, so a plateless message's sender name, body and meta line all
share one edge. Switching variant re-paints a message; it never resizes or
re-indents one.
Sizes
size drives the plate's inset, the type ladder AND the chrome inside it —
reaction chips, the voice transport and the file tile all step with it. Padding
is one ladder, not a phone/desktop split.
Anything nested inside the plate — a quoted reply, a media frame, a file card, a link preview — takes its radius one notch under the bubble's own, so it can never be rounder than the shape containing it. Pills (reactions, citations) are exempt: a pill is a shape, not a radius step.
Body text runs the data-display family ladder (tableTextSizeMap,
12/12/14/14/16) on desktop — shared with Table rather than re-declared — and
lifts one step on phones (16px at md), where a message is reading copy held
at arm's length and every platform messenger sets it at 16–17px. The two
halves are one map, written as disjoint max-md:/md: bands and
contract-pinned so they cannot drift.
Grouping and tails
grouped="first | middle | last" marks a run of consecutive messages from one
sender: the name and avatar render once, the joined corner steps down one notch
on the run's inline edge, and only the final bubble grows a tail.
Tails are opt-in. One surveyed messenger dropped the tail, a second made it a user setting, and the team-chat tools never had one — it is an ornament in 2026, not the default shape.
Delivery status
sending → sent → delivered → read, plus failed with a retry. Every state
renders an aria-hidden glyph and a text equivalent — a checkmark is not a
word. The failure text is visible rather than screen-reader-only, because it is
the one state the reader has to act on.
Put the meta on the run's last message only; that is where every surveyed product shows it.
Failed to send
A message whose status changes to failed is announced — "Not delivered", or your
statusLabels.failed — once, through the list's live region, which is mounted empty
from first paint. Outside a ChatBubbleList the meta mounts its own region. A
message that loads already failed stays silent, so render ChatBubbleMeta from the
first send rather than only once a message fails.
Reactions
Reaction chips are real aria-pressed buttons with an explicit accessible
name — the emoji and the count are decorative, so "👍 3" must not be the name.
They paint at 28px with 14px type — sized against the copy they annotate, not
down to the WCAG 2.5.8 24px floor — and extend their hit area to 44px below
md. Each carries its label as a hover/focus tip, because a chip cannot show
who reacted.
The cluster attaches to the plate, so it renders inside
ChatBubbleBody, not beside it:
<ChatBubbleBody>
Shipping today
<ChatBubbleReactions>
<ChatBubbleReaction emoji="👍" count={3} label="Like, 3 people" />
</ChatBubbleReactions>
</ChatBubbleBody>It is placed out of flow on purpose: in flow it grew the content column and dragged a bottom-aligned avatar down with it, so a message moved as soon as someone reacted. The row reserves the space instead. The cluster sits BELOW the plate rather than lapping onto it — an overlap covered the last line of a plateless message and crowded a plated one. On a bubble it takes the inner corner (the tail and the meta own the outer one); on a plateless message it follows the text's own edge, because there is no bubble to hang off.
Quoted replies
ChatBubbleReply is the affordance that starts one. It parks against the
message's inner edge, out of flow. With a mouse it fades in while the message
is hovered or holds focus; on a touch screen there is no hover, so it stays
visible.
Images and video
Voice, files and link previews
All three are display-only, controlled parts: the library never plays, uploads or fetches anything. Playback state, progress and the transport belong to your audio element.
Rich cards
Stickers and lone emoji
AI response
The assistant layout: the user keeps a bubble, the response goes flat and full
width with an action row underneath. A held feedback button sets pressed and
swaps to the filled glyph.
Streaming
Reasoning and tool steps
The reasoning trace
A thinking block is the same disclosure with a run behind it. Give
ChatBubbleSteps a status and it writes its own trigger — "Thinking"
while the model reasons, "Thought for 12s" once you hand it a durationMs you
measured, "Thinking interrupted" or "Thinking failed" when the run stops. The
span is formatted through Intl, in the strings' own language.
It stays collapsed while it streams. preview is a one-line echo of the trace
under the trigger — visible while streaming and collapsed, aria-hidden, and
never inside a live region, because reasoning tokens must not be announced
while the answer is what matters. Pass the whole trace: the line shows its
newest end and clips the start, so the tokens that just arrived stay in view.
The trace itself goes in a ChatBubbleTrace, a named scroll region you can
reach with the keyboard, so a long run never takes the whole transcript.
Nothing opens or closes itself. A run that ends leaves an open panel open, and
the collapsed state simply becomes its one-line summary; use open to drive it
yourself.
Citations
Sources are soft chips, not hairline outlines: a row of empty-looking boxes under a response reads as chrome rather than as links. Each keeps its footnote index and clears the 44px touch floor on phones.
Code in a response
Long messages
Message actions
ChatBubbleActions is the row under a message: copy, feedback, regenerate,
edit, share. Put it after ChatBubbleBody, so in the tab order it comes
right after its message.
With a mouse the row fades in while the message is hovered, while anything in
it has focus, and while a menu opened from it is up. On a touch screen it is
always visible. It fades by opacity only: the buttons stay in the tab order and
under the pointer the whole time, so a keyboard, a finger and VoiceOver all
reach them. Pass reveal="always" to keep it visible.
ChatBubbleAction is one button: an icon, a label that names it and fills
its tip, and pressed for a toggle. ChatBubbleCopy copies the message's
source markdown, not the rendered text, so a pasted answer keeps its bold,
its code and its links. Pass it the same string you render, or a function that
returns it. Outside a message row, variant="outline" makes it a labelled
button (a page's Share, say) with copiedChildren as its label once copied, and
variant="ghost" is the icon-only twin for a narrow column.
Regenerate branches
Regenerating a reply forks it. Keep every version, and ChatBubbleBranches
moves between them: "2 / 3" between previous and next. It announces "Version 2
of 3" when the version changes, and the button at either end is disabled but
stays focusable. It is controlled — swap the message's content, not the
message, so focus stays on the button that was pressed and the list keeps its
scroll position.
A menu of actions
A long-press or hover menu is a composition — the bubble is the anchor, the house menu owns the rows. Keep the trigger in the DOM at all times and reveal it on hover and focus (WCAG 1.4.13); a trigger that only exists on hover is unreachable by keyboard and by touch.
Group chat
System messages
The transcript
ChatBubbleList is the scroll container and the a11y contract: a named
role="log", stick-to-bottom that never moves the reader against their intent,
a jump-to-latest button when they have scrolled away, and an empty state.
Auto-scroll
The list follows the newest content only while the reader is already at the bottom. One rule tells their scroll from its own: a pinned scroll only ever moves down, so any upward move of the scroll position is theirs — wheel, drag, touch flick, scrollbar or keyboard paging alike — and it releases the list and stops any scroll already in flight. Growth while released moves nothing at all, because content arriving below the fold does not shift what is on screen. Returning to the bottom re-arms it.
While they are away the button counts what has arrived. A count is messages, not tokens: one direct child is one message, so a streaming response lengthens without ever ticking it. Focus is never moved, and under reduced motion every scroll lands instantly instead of tweening.
The list also re-pins when its own box shrinks — a software keyboard
opening under a viewport-sized column, a pane resizing — instantly, because the
reader has not moved and a tween there reads as drift. A list with a fixed
height needs no answer at all: nothing moved. Position the composer itself with
KeyboardToolbar, which owns the keyboard inset.
Scroll position is not preserved when messages are prepended — loading older history jumps. Hold the position yourself, or wait for the anchoring that is tracked separately.
On color
RTL
Skeleton
API Reference
ChatBubble props
| Prop | Type | Default | Description |
|---|---|---|---|
mine | boolean | false | Aligns to the inline end and takes the filled plate. |
senderName | ReactNode | — | Visible name; rendered once per run. |
senderBadge | ReactNode | — | Chip beside the name — role, pronouns, bot marker. |
avatar | ReactNode | — | Rendered in the row gutter. |
timestamp | ReactNode | — | Header-row timestamp. |
grouped | "first" | "middle" | "last" | — | Position in a consecutive run. |
variant | "filled" | "soft" | "outline" | "ghost" | derived from mine | Plate weight. |
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Density. |
rounded | "none" | "sm" | "md" | "lg" | "xl" | "full" | "xl" | Component-scoped ladder, one notch rounder than the canonical map (8–24px); xl and full both land on the 24px cap. |
tone | "default" | "on-color" | inherited | Derives the plates from the surface's ink: soft is the field's 8% well and filled the inverse chip, so text on either clears 4.5:1 on every plate. Inherited from a wrapping <Surface>, else "default"; an explicit prop wins. |
tail | boolean | false | Draws the nub on the group-final message. |
fullWidth | boolean | false | Let the message use the row's whole width instead of the shared reading measure. For an answer that is the content of a narrow column, such as a side panel or a sheet, where the measure would leave a fifth of it empty. Unset, every variant in a conversation keeps one measure, so a message never changes width when its variant does. |
streaming | boolean | false | Stamps data-streaming. |
deleted | boolean | false | Renders the tombstone instead of children. |
deletedLabel | string | "This message was deleted" | Tombstone text. |
mineLabel | string | "You" | sr-only identity for your own messages. |
identityLabel | string | null | — | Overrides, or removes, the sr-only identity. |
ChatBubbleBody props
| Prop | Type | Default | Description |
|---|---|---|---|
maxLines | number | — | Clamp with a "Show more" disclosure. |
showMoreLabel | string | "Show more" | |
showLessLabel | string | "Show less" |
ChatBubbleMeta props
| Prop | Type | Default | Description |
|---|---|---|---|
timestamp | ReactNode | — | |
status | "sending" | "sent" | "delivered" | "read" | "failed" | — | Glyph plus text equivalent. |
statusLabels | Partial<Record<Status, string>> | — | Localised wording. |
onRetry | () => void | — | Retry action, paired with failed. |
retryLabel | string | "Retry" | |
edited | boolean | false | |
editedLabel | string | "Edited" | |
placement | "outside" | "inside" | "outside" | inside floats into the plate corner. |
ChatBubbleList props
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | "Conversation" | Accessible name for the log. |
labelledBy | string | — | Use when a visible heading already names it. |
isStreaming | boolean | false | Sets aria-busy and announces. |
streamingLabel | string | "Generating response" | |
completeLabel | string | "Response complete" | |
autoScroll | boolean | true | Follows new content only at the bottom. |
jumpToLatestLabel | string | "Jump to latest message" | The button's label while nothing new has arrived. |
emptyState | ReactNode | — | Rendered when there are no children. |
bottomThreshold | number | 32 | px still counted as "at the bottom". |
dir | "ltr" | "rtl" | — | Unset inherits; set, it also picks the log's own strings. |
strings | Partial<ChatBubbleListStrings> | — | Per-entry overrides, newMessages included. |
strings carries label, streaming, complete, jumpToLatest and the
count-bearing newMessages ({count}), with the plural variants its language
inflects.
ChatBubbleReaction props
| Prop | Type | Default | Description |
|---|---|---|---|
emoji | ReactNode | — | Decorative. |
count | number | — | Decorative. |
pressed | boolean | — | Renders aria-pressed. |
label | string | — | Required accessible name. |
hint | ReactNode | label | Hover/focus tip. null suppresses it. |
ChatBubbleActions props
| Prop | Type | Default | Description |
|---|---|---|---|
reveal | "hover" | "always" | "hover" | hover fades the row in on a fine pointer while the message is hovered or focused; it is always visible on touch. |
ChatBubbleAction props
| Prop | Type | Default | Description |
|---|---|---|---|
icon | ReactNode | — | The glyph. Pass the filled glyph for a held state. |
label | string | — | Required accessible name, and the default tip. |
hint | ReactNode | label | Hover/focus tip. null suppresses it. |
pressed | boolean | — | A toggle action. Renders aria-pressed. |
Takes the button's own attributes (onClick, aria-disabled, …). Its size
steps with the bubble's size and it takes the bubble's tone.
ChatBubbleCopy props
| Prop | Type | Default | Description |
|---|---|---|---|
text | string | () => string | — | Required. The source markdown to copy. A function is read at click time. |
label | string | strings.copy | Accessible name and tip. |
variant | "action" | "outline" | "ghost" | "action" | action: the message row's icon action. outline: a labelled outline button, with children as its label, for a copy that stands outside a message row, such as a page's Share. ghost: the same control icon-only, its narrow-column twin. Both outside looks keep the copied glyph swap and the announcement. |
children | ReactNode | — | The visible label of the outline look. |
copiedChildren | ReactNode | — | The outline label while copied ("Copied"). Both labels share one cell, so the button keeps the wider width and never jumps. |
icon | ReactNode | the copy glyph | The resting glyph. |
hint | ReactNode | label | Tip on hover and focus. null suppresses it. |
writeToClipboard | (text: string) => boolean | Promise<boolean> | — | Replaces the clipboard write. Resolve false or throw to report a failure. |
dir | "ltr" | "rtl" | — | Picks the strings table — Arabic for rtl. |
strings | Partial<ChatBubbleActionsStrings> | — | Per-entry overrides. |
ChatBubbleBranches props
| Prop | Type | Default | Description |
|---|---|---|---|
branch | number | — | Required. The version on screen, 1-based. |
branchCount | number | — | Required. How many versions exist. Under 2 renders nothing. |
onBranchChange | (branch: number) => void | — | Called with the version to show. |
dir | "ltr" | "rtl" | — | Unset, the chevrons follow the direction around it; set to rtl, the strings are Arabic too. |
strings | Partial<ChatBubbleActionsStrings> | — | Per-entry overrides. |
strings carries copy, copied, copyFailed, previousVersion,
nextVersion, versionOf and counter; the last two take {branch} and
{count}.
ChatBubbleReply props
| Prop | Type | Default | Description |
|---|---|---|---|
onReply | () => void | — | Start a reply to this message. |
label | string | "Reply" | Accessible name, and the tip. |
ChatBubbleHint props
| Prop | Type | Default | Description |
|---|---|---|---|
label | ReactNode | — | Tip text. Empty renders the child untouched. |
children | ReactElement | — | The control. Rendered as-is, no wrapper. |
ChatBubbleSteps / ChatBubbleStep props
Both parts take a status, and the two mean different things: on Steps it is
the whole run, on Step it is one row's progress.
| Prop | Type | Default | Description |
|---|---|---|---|
label | ReactNode | — | Trigger text on Steps. Overrides every derived label; omit it to let status and durationMs write one. |
open / defaultOpen | boolean | false | Controlled / uncontrolled. Collapsed by default, streaming included — nothing opens or closes itself. |
onOpenChange | (open: boolean) => void | — | |
status | "idle" | "streaming" | "interrupted" | "failed" | "idle" | On Steps: the run's state, stamped as data-status. Each carries its own glyph and word. |
durationMs | number | — | How long the run took, measured by you. Rendered through Intl as "Thought for 12s". There is no internal timer. |
formatDuration | (ms: number) => string | — | Replaces the default span. |
preview | string | — | One-line echo of the trace, shown only while streaming and collapsed. aria-hidden; shows the trace's newest end. |
dir | "ltr" | "rtl" | — | Picks the strings table — Arabic for rtl. |
strings | Partial<ChatBubbleStepsStrings> | — | Overrides thinking, thoughtFor, interrupted, failed, trace per entry. |
busy | boolean | false | Deprecated — use status="streaming". An alias for one minor. |
status | "pending" | "running" | "complete" | "error" | "complete" | On Step. |
detail | ReactNode | — | Secondary line. |
statusLabel | string | — | Text equivalent of the status glyph. |
ChatBubbleTrace props
The reasoning text itself, inside a ChatBubbleSteps panel: a named scroll
region clamped to 12rem, always a tab stop.
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label | string | "Reasoning" | Names the region; the default follows dir. |
dir | "ltr" | "rtl" | — | Picks the strings table for that name. |
strings | Partial<ChatBubbleStepsStrings> | — | Same table as Steps. |
className | string | — | Override the clamp with your own max-h-*. |
Media and attachment parts
| Part | Prop | Type | Default | Description |
|---|---|---|---|---|
ChatBubbleMedia | src | string | — | Image source. Omit to paint the theme-aware placeholder frame. |
alt | string | "" | The image's text alternative. | |
ratio | string | "4 / 3" | A CSS aspect-ratio value. | |
video | boolean | false | Draws the play affordance and the duration of a video. | |
duration | ReactNode | — | Duration badge, e.g. "0:14". | |
caption | ReactNode | — | Caption under the frame, inside the same plate. | |
ChatBubbleVoice | playing · onPlayToggle | boolean · () => void | false | Controlled playback state and its toggle. |
progress | number | 0 | 0 to 1. Drives the played portion of the waveform. | |
duration | ReactNode | — | Length of the message. | |
playLabel · pauseLabel | string | "Play voice message" · "Pause voice message" | Names of the toggle. | |
footer | ReactNode | — | A row under the transport: a transcript link, a speed control. | |
ChatBubbleFile | name · meta · icon | ReactNode | — | The file's name, its human-readable size (you format it) and its glyph. |
href · downloadLabel | string | — · "Download" | With href, renders the trailing download action. | |
ChatBubbleLinkPreview | href · title · description · source · image | — | The linked page. source is the site name or domain; omit image for the placeholder frame. | |
ChatBubbleQuote | sender · onJump | ReactNode · () => void | — | Who wrote the quoted message. With onJump the quote is a button that jumps to the original. |
ChatBubbleSource | index · icon · citationId | number · ReactNode · string | — | Footnote number, leading glyph (a globe by default) and, inside Citations, the source it takes its number and link from. |
ChatBubbleSkeleton props
| Prop | Type | Default | Description |
|---|---|---|---|
messages | number | 3 | Placeholder rows; alternates sides. |
lines | number | 2 | Text lines per placeholder bubble. |
chars | number | 34 | Characters the widest line stands in for. |
showAvatar | boolean | true | Reserve the avatar gutter, as a real transcript does. |
showHeader | boolean | true | Draw the sender and timestamp line above each plate. A real bubble draws that row only when given a name or a timestamp, so turn it off for a transcript of bare bubbles. |
variant · size · rounded | as ChatBubble | "soft" · "md" · "xl" | The plate the placeholder stands in for. |
label | string | — | Announces the loading region. Silent when omitted. |
lines | number | 2 | Text lines per bubble. |
chars | number | 34 | Characters the widest line stands in for. |
size / rounded / variant | — | md / xl / soft | Same frame as the real bubble. |
showAvatar | boolean | true | Reserve the gutter. |
label | string | — | Announce the region. Silent when omitted. |
Other parts — ChatBubbleTail, ChatBubbleQuote,
ChatBubbleReactions, ChatBubbleTyping,
ChatBubbleMedia, ChatBubbleVoice, ChatBubbleFile,
ChatBubbleLinkPreview, ChatBubbleSources, ChatBubbleSource,
ChatBubbleSystem — take their own documented props plus the usual
className and DOM attributes.
Migrating from a Message component
The common Message pattern is a layout wrapper — row, alignment, avatar,
header, footer — around a separate Bubble surface. That anatomy maps onto
this component directly:
| Message prop | ChatBubble |
|---|---|
Message with align="start" | "end" | ChatBubble with mine |
MessageAvatar | the avatar prop |
MessageContent | the content column, built in |
MessageHeader | senderName / senderBadge / timestamp |
Bubble · BubbleContent | ChatBubbleBody |
MessageFooter | ChatBubbleMeta · ChatBubbleActions |
MessageGroup | grouped |
Marker role="status" | the list's streaming announcer |
Two differences are deliberate.
Grouping needs no boilerplate. That pattern asks you to render an empty
MessageAvatar on the earlier messages of a run so they stay aligned with the
avatar on the last one. grouped reserves that gutter itself — pass avatar
only to the message that shows one.
The header follows the message's side. That pattern pins headers to the
start at both alignments; here a mine message mirrors completely, header
included, so one side is the exact mirror image of the other.
Accessibility
The parts of this contract that are easy to get wrong, and what the component does about them.
- The transcript is
role="log", which carries an implicitaria-live="polite"andaria-atomic="false"— only the appended message is announced, never the whole scrollback. It is deliberately notrole="feed": that is the infinite-article-stream pattern and obligesarticle+aria-posinseton every row plus its own keyboard contract. - Streaming never writes into a live region. Tokens go straight into the
bubble while the log is
aria-busy, and a separate always-mountedrole="status"announces the start and the completion once each. Writing tokens into a live region makes a screen reader speak every partial update. - The announcer is mounted empty from first paint. A live region injected into the DOM later never announces at all.
- Sender identity is in the markup. "Right and blue means me" conveys
nothing to a screen reader, so a message with no visible name renders an
sr-only prefix — "You" on your own messages, the sender's name on grouped
continuations. Override with
identityLabel, or remove it withidentityLabel={null}when the surrounding markup already names the sender. - Timestamps stay in the DOM. Hide them visually if you want hover-only density; never remove the node.
- Focus is never moved to an arriving message. New content must not steal focus from the composer; the jump-to-latest button is the explicit path back.
- DOM order matches visual order. Never group two senders' messages in the DOM and interleave them with CSS — a screen reader would read one person's entire run before the other's.
- Reactions and step statuses carry text, not just a glyph or a colour.
- Reasoning tokens are never announced. The trace streams into the panel,
and the collapsed
previewline isaria-hidden— neither sits in a live region. A screen-reader user hears the answer, not the model's thinking. - A stopped run announces once. Entering
interruptedorfailedspeaks through the list's one announcer, or through the steps' ownrole="status"region outside a list. Starting and finishing stay silent, because the list already announces those. - The action row is never hover-only. On a touch screen it is always
visible; with a mouse it also shows on focus, so the keyboard path from a
message to its row works without a pointer. It fades by opacity alone —
never
display,visibilityorpointer-events: none, which would take the buttons out of the tab order or break VoiceOver's click-through. - Every action is a real button with a name.
ChatBubbleActionrequireslabel, and the tip repeats it for a sighted mouse user. - Copy and a version change announce once. Inside a list they speak through the list's one announcer; outside one, each part mounts its own region beside the button, because WebKit exposes no live region inside a button.
- The version switcher is a sentence to a screen reader. "2 / 3" is
aria-hidden; "Version 2 of 3" sits in its place. The first and last versions disable their button witharia-disabled, which keeps focus on it. - The trace is a named, keyboard-reachable scroll region. It is a
role="group"rather than a landmark, so a transcript full of them does not fill the page outline, and it is always a tab stop — whether the text overflows is a measurement the server cannot make.
VoiceOver + Safari passed 2026-09-14: "Ada Lovelace, image", the message text, "09:12", then "You, image", "Perfect — I'll read them before standup.", "09:14", "Read". NVDA and JAWS are untested — they cannot
be run on the maintainers' machines, and this component is beta until they
are.
Agent notes
- The root is a row, the body is the plate. Put meta, actions and
sources as siblings of
ChatBubbleBody. Reactions are the exception: the cluster attaches to the plate, so it goes inside the body. - Rich content (cards, polls, code, charts) is children, never a prop.
- Set
groupedon consecutive messages from one sender, and render the avatar and meta only on the run'slastmessage. ChatBubbleReactionrequireslabel; the emoji and count are aria-hidden.- Wrap a transcript in
ChatBubbleList— it is where the log semantics, the streaming announcements and the scroll behaviour live. Do not add your ownaria-livearound streaming text. variant="ghost"is the AI/team-chat layout: no plate, full width — but the same inset and the same measure as every other variant, carried on the column.- Every part follows the column's edge, so a
minemessage is an exact mirror of a received one — the steps block included. - For a thinking block, give
ChatBubbleStepsastatusand put the trace in aChatBubbleTrace. Leavelabelout: the trigger writes itself from the status and thedurationMsyou measured. Measure the run yourself — the component does not time it, so a remount cannot invent a span. - Pass the whole trace as
preview. The line pins its newest end and clips the start, so it always shows what just arrived — do not slice it. - Do not auto-open or auto-collapse the panel as a run starts and ends. It is
collapsed by default and stays where the reader left it;
openis there if you need to drive it. - Avatars follow the layout family. A bubble run bottom-anchors its avatar and hangs it on the run's LAST message, beside the tail. A plateless run top-anchors it beside the NAME line and hangs it on the FIRST. The component handles the anchoring; you choose the message.
- Wrap an icon-only control in
ChatBubbleHintto give it a tip. The houseTooltipis a presentation CARD — aw-64+ panel with title, description and actions, and no trigger binding — so it cannot describe a button. Hint takes its behaviour from Base UI (hover, focus, Escape) and its PAINT from the sameinverseOverlayClassesrecipe that Tooltip's own plate uses, so the two match without either retyping the surface contract. ChatBubbleReplyis the quick reply affordance. It reveals on the message's own group — noclassName="group"needed — and is always visible on touch. Like the reaction cluster it is out of flow, so a repliable message is exactly the size of one that is not.- Put
ChatBubbleActionsafter the body and fill it withChatBubbleAction,ChatBubbleCopyandChatBubbleBranches. Never hide the row withdisplay,visibilityorpointer-events: none; its opacity reveal is the only kind that keeps it reachable. - Copy the source, not the DOM. Pass
ChatBubbleCopythe markdown you render.innerTextloses the markdown. - For regenerated replies, keep every version and render
ChatBubbleBrancheswithbranchandbranchCount. Swap the message's content when the version changes — do not key the message by version, or it remounts and focus is lost.