ChatBubbleNew
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.
Playground
Installation
pnpm add @tessinaui/uiUsage
import {
ChatBubbleNew,
ChatBubbleNewBody,
ChatBubbleNewMeta,
ChatBubbleNewList,
} from "@tessinaui/ui";
<ChatBubbleNewList label="Conversation">
<ChatBubbleNew senderName="Ada" avatar={<Avatar name="Ada" size="md" />}>
<ChatBubbleNewBody>The notes are ready.</ChatBubbleNewBody>
<ChatBubbleNewMeta timestamp="09:12" />
</ChatBubbleNew>
<ChatBubbleNew mine>
<ChatBubbleNewBody>Reading them now.</ChatBubbleNewBody>
<ChatBubbleNewMeta timestamp="09:14" status="read" />
</ChatBubbleNew>
</ChatBubbleNewList>The root is the row: it owns alignment, the avatar gutter, the header and
the sr-only sender identity. ChatBubbleNewBody 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.
When to use — and when not to
- Composing a message →
PromptInput, not this. This component only displays. - A comment thread or activity feed → compose
Card/Avatar; a chat bubble implies a live, ordered conversation. - A transient confirmation →
Toast. - Code, charts or tables inside a response → compose them as children.
The body tolerates block content; there is no
codeorcardprop, on purpose (a typed enum for rich content is how a component ends up owning eighteen content types).
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. Messenger dropped the tail, Telegram made it a user setting, Teams and every team-chat tool 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.
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
ChatBubbleNewBody, not beside it:
<ChatBubbleNewBody>
Shipping today
<ChatBubbleNewReactions>
<ChatBubbleNewReaction emoji="👍" count={3} label="Like, 3 people" />
</ChatBubbleNewReactions>
</ChatBubbleNewBody>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
ChatBubbleNewReply is the affordance that starts one. It parks against the
message's inner edge, out of flow, and reveals on hover above md — on touch
there is no hover, so it stays visible. Put className="group" on the row.
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.
Streaming
Reasoning and tool steps
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
The hover/long-press 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
ChatBubbleNewList 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.
On color
RTL
Skeleton
Coming from shadcn/ui Message
shadcn's Message is a layout wrapper — row, alignment, avatar, header, footer —
around a separate Bubble surface. The anatomy maps onto this component
directly:
| shadcn/ui | ChatBubbleNew |
|---|---|
Message with align="start" | "end" | ChatBubbleNew with mine |
MessageAvatar | the avatar prop |
MessageContent | the content column, built in |
MessageHeader | senderName / senderBadge / timestamp |
Bubble · BubbleContent | ChatBubbleNewBody |
MessageFooter | ChatBubbleNewMeta · ChatBubbleNewActions |
MessageGroup | grouped |
Marker role="status" | the list's streaming announcer |
Two differences are deliberate.
Grouping needs no boilerplate. shadcn 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. shadcn 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.
Verified with VoiceOver on macOS. NVDA and JAWS are untested — they cannot
be run on the maintainers' machines, and this component is beta until they
are.
API Reference
ChatBubbleNew 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" | "default" | Derives every plate from currentColor. |
tail | boolean | false | Draws the nub on the group-final message. |
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. |
ChatBubbleNewBody props
| Prop | Type | Default | Description |
|---|---|---|---|
maxLines | number | — | Clamp with a "Show more" disclosure. |
showMoreLabel | string | "Show more" | |
showLessLabel | string | "Show less" |
ChatBubbleNewMeta 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. |
ChatBubbleNewList 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" | |
emptyState | ReactNode | — | Rendered when there are no children. |
bottomThreshold | number | 32 | px still counted as "at the bottom". |
ChatBubbleNewReaction 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. |
ChatBubbleNewReply props
| Prop | Type | Default | Description |
|---|---|---|---|
onReply | () => void | — | Start a reply to this message. |
label | string | "Reply" | Accessible name, and the tip. |
ChatBubbleNewHint props
| Prop | Type | Default | Description |
|---|---|---|---|
label | ReactNode | — | Tip text. Empty renders the child untouched. |
children | ReactElement | — | The control. Rendered as-is, no wrapper. |
ChatBubbleNewSteps / ChatBubbleNewStep props
| Prop | Type | Default | Description |
|---|---|---|---|
label | ReactNode | — | Trigger text on Steps. |
open / defaultOpen | boolean | false | Controlled / uncontrolled. |
onOpenChange | (open: boolean) => void | — | |
busy | boolean | false | Spinner in the trigger. |
status | "pending" | "running" | "complete" | "error" | "complete" | On Step. |
detail | ReactNode | — | Secondary line. |
statusLabel | string | — | Text equivalent of the status glyph. |
ChatBubbleNewSkeleton props
| Prop | Type | Default | Description |
|---|---|---|---|
messages | number | 3 | Placeholder rows; alternates sides. |
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 — ChatBubbleNewTail, ChatBubbleNewQuote,
ChatBubbleNewReactions, ChatBubbleNewActions, ChatBubbleNewTyping,
ChatBubbleNewMedia, ChatBubbleNewVoice, ChatBubbleNewFile,
ChatBubbleNewLinkPreview, ChatBubbleNewSources, ChatBubbleNewSource,
ChatBubbleNewSystem — take their own documented props plus the usual
className and DOM attributes.
Agent notes
- The root is a row, the body is the plate. Put meta, actions and
sources as siblings of
ChatBubbleNewBody. 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. ChatBubbleNewReactionrequireslabel; the emoji and count are aria-hidden.- Wrap a transcript in
ChatBubbleNewList— 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. - Avatars follow the layout family. A bubble run bottom-anchors its avatar and hangs it on the run's LAST message, beside the tail (iMessage). A plateless run top-anchors it beside the NAME line and hangs it on the FIRST (Slack). The component handles the anchoring; you choose the message.
- Wrap an icon-only control in
ChatBubbleNewHintto 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. ChatBubbleNewReplyis the quick reply affordance. PutclassName="group"on the row; it reveals on hover abovemdand 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.