DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
ChatBubbleStreamingResponseCitationsToolCallPlanListParameterDiffModelSelectorElicitationArtifactPanelPromptInputKeyboardToolbar
AI agentsContributing
  1. Components
  2. ChatBubble

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/ui

When 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. grouped renders the name and avatar once and joins the corners.
  • Delivery you need to show. ChatBubbleMeta carries 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 ghost variant keeps the response full width.
  • A transcript that must announce itself. ChatBubbleList is a named role="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 code or card prop, on purpose.

Installation

Terminal
pnpm add @tessinaui/ui

Usage

example.tsx
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 ChatBubbleChatBubble
role="user" / role="assistant"mine / no mine
variant="bubble" / "flat" / "card"variant="filled" / "ghost" / "outline" ("soft" is new)
size, rounded, dirthe same props. rounded also takes "xl"
maxWidtha shared reading measure; fullWidth lets a message use the whole row
childrenChatBubbleBody inside the row
avatar, showAvataravatar={<Avatar … />}. TessinaAvatar is gone; compose Avatar
timestamp, showTimestamptimestamp on ChatBubbleMeta, beside the body
status="sending"status="sending" on ChatBubbleMeta
status="error" + onRetrystatus="failed" + onRetry on ChatBubbleMeta
status="streaming"streaming on the row, and ChatBubbleTyping until the first text arrives
showActions + onCopyChatBubbleActions holding a ChatBubbleCopy
onThumbsUp, onThumbsDown, onRegenerateone ChatBubbleAction each, with an icon, a required label and, for a thumb that is on, pressed
intentnone. Put status colour in the words, or give the row a tone
stringsstatusLabels and retryLabel on the meta, strings on the action parts
StreamingIndicatorChatBubbleTyping
a hand-made role="log" wrapperChatBubbleList

An assistant message with actions, before and after:

example.tsx
// 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:

example.tsx
// 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:

example.tsx
<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

PropTypeDefaultDescription
minebooleanfalseAligns to the inline end and takes the filled plate.
senderNameReactNode—Visible name; rendered once per run.
senderBadgeReactNode—Chip beside the name — role, pronouns, bot marker.
avatarReactNode—Rendered in the row gutter.
timestampReactNode—Header-row timestamp.
grouped"first" | "middle" | "last"—Position in a consecutive run.
variant"filled" | "soft" | "outline" | "ghost"derived from minePlate 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"inheritedDerives 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.
tailbooleanfalseDraws the nub on the group-final message.
fullWidthbooleanfalseLet 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.
streamingbooleanfalseStamps data-streaming.
deletedbooleanfalseRenders the tombstone instead of children.
deletedLabelstring"This message was deleted"Tombstone text.
mineLabelstring"You"sr-only identity for your own messages.
identityLabelstring | null—Overrides, or removes, the sr-only identity.

ChatBubbleBody props

PropTypeDefaultDescription
maxLinesnumber—Clamp with a "Show more" disclosure.
showMoreLabelstring"Show more"
showLessLabelstring"Show less"

ChatBubbleMeta props

PropTypeDefaultDescription
timestampReactNode—
status"sending" | "sent" | "delivered" | "read" | "failed"—Glyph plus text equivalent.
statusLabelsPartial<Record<Status, string>>—Localised wording.
onRetry() => void—Retry action, paired with failed.
retryLabelstring"Retry"
editedbooleanfalse
editedLabelstring"Edited"
placement"outside" | "inside""outside"inside floats into the plate corner.

ChatBubbleList props

PropTypeDefaultDescription
labelstring"Conversation"Accessible name for the log.
labelledBystring—Use when a visible heading already names it.
isStreamingbooleanfalseSets aria-busy and announces.
streamingLabelstring"Generating response"
completeLabelstring"Response complete"
autoScrollbooleantrueFollows new content only at the bottom.
jumpToLatestLabelstring"Jump to latest message"The button's label while nothing new has arrived.
emptyStateReactNode—Rendered when there are no children.
bottomThresholdnumber32px still counted as "at the bottom".
dir"ltr" | "rtl"—Unset inherits; set, it also picks the log's own strings.
stringsPartial<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

PropTypeDefaultDescription
emojiReactNode—Decorative.
countnumber—Decorative.
pressedboolean—Renders aria-pressed.
labelstring—Required accessible name.
hintReactNodelabelHover/focus tip. null suppresses it.

ChatBubbleActions props

PropTypeDefaultDescription
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

PropTypeDefaultDescription
iconReactNode—The glyph. Pass the filled glyph for a held state.
labelstring—Required accessible name, and the default tip.
hintReactNodelabelHover/focus tip. null suppresses it.
pressedboolean—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

PropTypeDefaultDescription
textstring | () => string—Required. The source markdown to copy. A function is read at click time.
labelstringstrings.copyAccessible 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.
childrenReactNode—The visible label of the outline look.
copiedChildrenReactNode—The outline label while copied ("Copied"). Both labels share one cell, so the button keeps the wider width and never jumps.
iconReactNodethe copy glyphThe resting glyph.
hintReactNodelabelTip 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.
stringsPartial<ChatBubbleActionsStrings>—Per-entry overrides.

ChatBubbleBranches props

PropTypeDefaultDescription
branchnumber—Required. The version on screen, 1-based.
branchCountnumber—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.
stringsPartial<ChatBubbleActionsStrings>—Per-entry overrides.

strings carries copy, copied, copyFailed, previousVersion, nextVersion, versionOf and counter; the last two take {branch} and {count}.

ChatBubbleReply props

PropTypeDefaultDescription
onReply() => void—Start a reply to this message.
labelstring"Reply"Accessible name, and the tip.

ChatBubbleHint props

PropTypeDefaultDescription
labelReactNode—Tip text. Empty renders the child untouched.
childrenReactElement—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.

PropTypeDefaultDescription
labelReactNode—Trigger text on Steps. Overrides every derived label; omit it to let status and durationMs write one.
open / defaultOpenbooleanfalseControlled / 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.
durationMsnumber—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.
previewstring—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.
stringsPartial<ChatBubbleStepsStrings>—Overrides thinking, thoughtFor, interrupted, failed, trace per entry.
busybooleanfalseDeprecated — use status="streaming". An alias for one minor.
status"pending" | "running" | "complete" | "error""complete"On Step.
detailReactNode—Secondary line.
statusLabelstring—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.

PropTypeDefaultDescription
aria-labelstring"Reasoning"Names the region; the default follows dir.
dir"ltr" | "rtl"—Picks the strings table for that name.
stringsPartial<ChatBubbleStepsStrings>—Same table as Steps.
classNamestring—Override the clamp with your own max-h-*.

Media and attachment parts

PartPropTypeDefaultDescription
ChatBubbleMediasrcstring—Image source. Omit to paint the theme-aware placeholder frame.
altstring""The image's text alternative.
ratiostring"4 / 3"A CSS aspect-ratio value.
videobooleanfalseDraws the play affordance and the duration of a video.
durationReactNode—Duration badge, e.g. "0:14".
captionReactNode—Caption under the frame, inside the same plate.
ChatBubbleVoiceplaying · onPlayToggleboolean · () => voidfalseControlled playback state and its toggle.
progressnumber00 to 1. Drives the played portion of the waveform.
durationReactNode—Length of the message.
playLabel · pauseLabelstring"Play voice message" · "Pause voice message"Names of the toggle.
footerReactNode—A row under the transport: a transcript link, a speed control.
ChatBubbleFilename · meta · iconReactNode—The file's name, its human-readable size (you format it) and its glyph.
href · downloadLabelstring— · "Download"With href, renders the trailing download action.
ChatBubbleLinkPreviewhref · title · description · source · image—The linked page. source is the site name or domain; omit image for the placeholder frame.
ChatBubbleQuotesender · onJumpReactNode · () => void—Who wrote the quoted message. With onJump the quote is a button that jumps to the original.
ChatBubbleSourceindex · icon · citationIdnumber · ReactNode · string—Footnote number, leading glyph (a globe by default) and, inside Citations, the source it takes its number and link from.

ChatBubbleSkeleton props

PropTypeDefaultDescription
messagesnumber3Placeholder rows; alternates sides.
linesnumber2Text lines per placeholder bubble.
charsnumber34Characters the widest line stands in for.
showAvatarbooleantrueReserve the avatar gutter, as a real transcript does.
showHeaderbooleantrueDraw 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 · roundedas ChatBubble"soft" · "md" · "xl"The plate the placeholder stands in for.
labelstring—Announces the loading region. Silent when omitted.
linesnumber2Text lines per bubble.
charsnumber34Characters the widest line stands in for.
size / rounded / variant—md / xl / softSame frame as the real bubble.
showAvatarbooleantrueReserve the gutter.
labelstring—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 propChatBubble
Message with align="start" | "end"ChatBubble with mine
MessageAvatarthe avatar prop
MessageContentthe content column, built in
MessageHeadersenderName / senderBadge / timestamp
Bubble · BubbleContentChatBubbleBody
MessageFooterChatBubbleMeta · ChatBubbleActions
MessageGroupgrouped
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 implicit aria-live="polite" and aria-atomic="false" — only the appended message is announced, never the whole scrollback. It is deliberately not role="feed": that is the infinite-article-stream pattern and obliges article + aria-posinset on 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-mounted role="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 with identityLabel={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 preview line is aria-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 interrupted or failed speaks through the list's one announcer, or through the steps' own role="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, visibility or pointer-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. ChatBubbleAction requires label, 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 with aria-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 grouped on consecutive messages from one sender, and render the avatar and meta only on the run's last message.
  • ChatBubbleReaction requires label; 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 own aria-live around 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 mine message is an exact mirror of a received one — the steps block included.
  • For a thinking block, give ChatBubbleSteps a status and put the trace in a ChatBubbleTrace. Leave label out: the trigger writes itself from the status and the durationMs you 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; open is 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 ChatBubbleHint to give it a tip. The house Tooltip is a presentation CARD — a w-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 same inverseOverlayClasses recipe that Tooltip's own plate uses, so the two match without either retyping the surface contract.
  • ChatBubbleReply is the quick reply affordance. It reveals on the message's own group — no className="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 ChatBubbleActions after the body and fill it with ChatBubbleAction, ChatBubbleCopy and ChatBubbleBranches. Never hide the row with display, visibility or pointer-events: none; its opacity reveal is the only kind that keeps it reachable.
  • Copy the source, not the DOM. Pass ChatBubbleCopy the markdown you render. innerText loses the markdown.
  • For regenerated replies, keep every version and render ChatBubbleBranches with branch and branchCount. Swap the message's content when the version changes — do not key the message by version, or it remounts and focus is lost.
Activity HeatmapStreamingResponse

On this page

When to useWhen not to useInstallationUsagemine, not fromComing from the previous ChatBubbleExamplesDefaultVariantsSizesGrouping and tailsDelivery statusFailed to sendReactionsQuoted repliesImages and videoVoice, files and link previewsRich cardsStickers and lone emojiAI responseStreamingReasoning and tool stepsThe reasoning traceCitationsCode in a responseLong messagesMessage actionsRegenerate branchesA menu of actionsGroup chatSystem messagesThe transcriptAuto-scrollOn colorRTLSkeletonAPI ReferenceChatBubble propsChatBubbleBody propsChatBubbleMeta propsChatBubbleList propsChatBubbleReaction propsChatBubbleActions propsChatBubbleAction propsChatBubbleCopy propsChatBubbleBranches propsChatBubbleReply propsChatBubbleHint propsChatBubbleSteps / ChatBubbleStep propsChatBubbleTrace propsMedia and attachment partsChatBubbleSkeleton propsMigrating from a Message componentAccessibilityAgent notes