DocumentationComponentsTheme CreatorGitHub
Theme CreatorGitHubIntroduction
InstallationUsageTheming
ComponentsAccordionAction SheetAlertAlertDialogArea ChartAspectRatioAvatarBadgeBannerBar ChartBottom NavBreadcrumbButtonButtonGroupCalendarCardCarouselChartChatBubbleChatBubbleNewCheckboxChipCoachMarkCodeBlockCollapsibleColor PickerComboboxCommandContainerContextMenuDate PickerDividerDrawerDropdown MenuEmptyStateFABFieldFieldsetFile UploadFlexFormGridHoverCardIconButtonLabelLine ChartLinkMenubarMeterModalNavigation MenuNumberFieldOTP InputPaginationPickerPie ChartPopoverProgressPromptInputRadar ChartRadial ChartRadioRatingScroll AreaSearchSegmentedControlSelectShortcutSidebarSkeletonSliderCircularSliderMediaTrimmerSpacerSpinnerSplit ButtonStackStatusStepperSurfaceSwitchTableTabsTextareaTime PickerToastToggleButtonToggleGroupTokenizerToolbarTooltipTop Header DesktopTop Header Mobile
Contributing
Components

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

Usage

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 code or card prop, 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/uiChatBubbleNew
Message with align="start" | "end"ChatBubbleNew with mine
MessageAvatarthe avatar prop
MessageContentthe content column, built in
MessageHeadersenderName / senderBadge / timestamp
Bubble · BubbleContentChatBubbleNewBody
MessageFooterChatBubbleNewMeta · ChatBubbleNewActions
MessageGroupgrouped
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 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.

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

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""default"Derives every plate from currentColor.
tailbooleanfalseDraws the nub on the group-final message.
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.

ChatBubbleNewBody props

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

ChatBubbleNewMeta 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.

ChatBubbleNewList 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"
emptyStateReactNode—Rendered when there are no children.
bottomThresholdnumber32px still counted as "at the bottom".

ChatBubbleNewReaction props

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

ChatBubbleNewReply props

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

ChatBubbleNewHint props

PropTypeDefaultDescription
labelReactNode—Tip text. Empty renders the child untouched.
childrenReactElement—The control. Rendered as-is, no wrapper.

ChatBubbleNewSteps / ChatBubbleNewStep props

PropTypeDefaultDescription
labelReactNode—Trigger text on Steps.
open / defaultOpenbooleanfalseControlled / uncontrolled.
onOpenChange(open: boolean) => void—
busybooleanfalseSpinner in the trigger.
status"pending" | "running" | "complete" | "error""complete"On Step.
detailReactNode—Secondary line.
statusLabelstring—Text equivalent of the status glyph.

ChatBubbleNewSkeleton props

PropTypeDefaultDescription
messagesnumber3Placeholder rows; alternates sides.
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 — 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 grouped on consecutive messages from one sender, and render the avatar and meta only on the run's last message.
  • ChatBubbleNewReaction requires label; 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 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.
  • 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 ChatBubbleNewHint 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.
  • ChatBubbleNewReply is the quick reply affordance. Put className="group" on the row; it reveals on hover above md 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.
ChatBubbleCheckbox

On this page

PlaygroundInstallationUsagemine, not fromWhen to use — and when not toExamplesDefaultVariantsSizesGrouping and tailsDelivery statusReactionsQuoted repliesImages and videoVoice, files and link previewsRich cardsStickers and lone emojiAI responseStreamingReasoning and tool stepsCitationsCode in a responseLong messagesMessage actionsGroup chatSystem messagesThe transcriptOn colorRTLSkeletonComing from shadcn/ui MessageAccessibilityAPI ReferenceChatBubbleNew propsChatBubbleNewBody propsChatBubbleNewMeta propsChatBubbleNewList propsChatBubbleNewReaction propsChatBubbleNewReply propsChatBubbleNewHint propsChatBubbleNewSteps / ChatBubbleNewStep propsChatBubbleNewSkeleton propsAgent notes