Skip to content
Component installs need the registry setup
VegaStack Design

Bubble

Displays conversational content in a message bubble — seven variants, start/end alignment, grouping, edge-anchored reactions, and collapsible content.

Status
stable
Since
0.1.0
Accessibility pattern
presentational surface

Last updated

Hey there! what's up?
Hey! Want to see chat bubbles?
I can group messages, switch sides, and keep the whole thread easy to scan.
Sure. Hit me with your best demo.
Yes. You are reading a demo that is demoing itself. Very meta. Very on-brand.

Install

Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Bubble before installation. Retain the digest printed by this command.

pnpm exec vegastack-design verify bubble

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/bubble

Run the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.

Usage

import { Bubble, BubbleContent, BubbleReactions } from "@/components/ui/bubble";

<Bubble>
  <BubbleContent>
    I checked the registry output and removed the stale route.
  </BubbleContent>
  <BubbleReactions>
    <span>👍</span>
  </BubbleReactions>
</Bubble>;

Bubble is scoped to the bubble surface: framed chat text, short structured output, quoted replies, suggestions and reactions. Avatars, names, timestamps and message-level actions belong to Message, which owns the row around it.

Hey there! what's up?
Hey! Want to see chat bubbles?
I can group messages, switch sides, and keep the whole thread easy to scan.
Sure. Hit me with your best demo.
Yes. You are reading a demo that is demoing itself. Very meta. Very on-brand.

Anatomy

Bubble — data-slot="bubble"
BubbleContent
BubbleGroup — data-slot="bubble-group"
BubbleReactions — data-slot="bubble-reactions"

Examples

Composition

Bubble is the container; BubbleContent is the surface that carries the text — the variant colours the content element, so padding and the rounded corners stay put. BubbleReactions floats over the bubble edge.

Bubble
├── BubbleContent
└── BubbleReactions

BubbleGroup stacks consecutive bubbles from the same sender. The align prop stays on each Bubble, not on the group.

BubbleGroup
├── Bubble
│   └── BubbleContent
└── Bubble
    └── BubbleContent
I checked the registry output and removed the stale route.
Two bubbles, one sender.
The group owns the stacking; each bubble still owns its own variant and alignment.

Features

  • Seven visual variants, from a strong primary bubble to unframed ghost content
  • Start and end alignment for sender and receiver bubbles
  • Reactions that anchor to the bubble edge with configurable side and alignment
  • Bubbles size to their content, up to 80% of the container width
  • Polymorphic content via render for link and button bubbles
  • Customizable styling through the className prop on every part
Short.
A longer message stops at 80% of the container width, so the row never reads as full-bleed text.
Reactions anchor to the edge.
Ghost is unframed and full width, for assistant output that should not look like a chat surface at all.

Variants

Use variant to change the visual treatment of the bubble.

VariantDescription
defaultA strong primary bubble, usually for the current user.
secondaryThe standard neutral bubble for conversation content.
mutedA lower-emphasis bubble for quiet supporting content.
tintedA subtle primary-tinted bubble.
outlineA bordered bubble for secondary or rich content.
ghostUnframed content for assistant text or rich content.
destructiveA destructive bubble for error or failed actions.

A bubble sizes to its content, up to 80% of the container width. The ghost variant removes the max-width so assistant text and rich content can span the full row.

This is the default primary bubble.
This is the secondary variant.
This one is muted. It uses a lower emphasis color for the chat bubble.
This one is tinted. The tint is a softer color derived from the primary color.
We can also use an outlined variant.
Or a destructive variant with a reaction.

Ghost bubbles work for assistant text, markdown, and other content that should not be framed.

This is perfect for assistant messages that should not have a frame and can take the full width of the container. You can also render code in it.

Ghost bubbles are full width and can take the full width of the container.

Alignment

Use align on Bubble to align the bubble to the start or end of the conversation.

alignDescription
startAlign the bubble to the start of the conversation.
endAlign the bubble to the end of the conversation.

When building a chat interface, put the alignment on Message instead — the row's data-align reaches the bubble through group-data-[align=end]/message:self-end.

This bubble is aligned to the start. This is the default alignment.
This bubble is aligned to the end. Use this for user messages.

Bubble Group

Use BubbleGroup to group consecutive bubbles from the same sender. The align prop belongs on each Bubble, not on the group.

Can you tell me what's the issue?
You tell me!
It worked yesterday. You broke it!
Find the bug and fix it.
Want me to diff yesterday's you against today's you? It's a bit embarrassing.

Turn a bubble into a link or a button with the render prop on BubbleContent. It renders a real <button> or <a>, so it is focusable, exposes the correct role, and takes the global 2px :focus-visible outline from base.css.

<Bubble variant="muted">
  <BubbleContent render={<button type="button" />}>Click here</BubbleContent>
</Bubble>
How can I help you today?

Reactions

BubbleReactions holds reactions or quick action buttons. side (top / bottom) and align (start / end) position the row against the bubble edge. Reactions overlap that edge, so leave vertical space between rows — the example below uses a larger gap for exactly that reason.

I don't need tests, I know my code works.
Bold. Fine I'll add some tests. I'll let you know when they're done.
Tests passed on the first try. All 142 of them. Looking good!
Are you sure I can run this command?

Show More / Collapsible

Long bubble content composes with Collapsible for a show more / show less interaction. CollapsibleTrigger is the control, rendered as a link Button inside the bubble surface.

How can I help you today?
The accessibility review found two focus states that were visually too subtle in dark mode. I checked the dialog, menu, and drawer paths because each one renders focusable control...

Tooltip

Wrap a control in the reactions row in a Tooltip to reveal metadata on hover, such as when a message was read.

Did you remove the stale route?
Yes, removed it from the registry.

Popover

Pair a bubble with a Popover to surface more information on demand, such as the full error message behind a failed action.

Run the build script.
Failed to run the command.

API Reference

Bubble

The root bubble wrapper. A <div>; every other div prop passes through.

PropTypeDefaultDescription
variant"default" | "secondary" | "muted" | "tinted" | "outline" | "ghost" | "destructive""default"The bubble visual treatment.
align"start" | "end""start"The inline alignment of the bubble.
classNamestring—Additional classes to apply to the root element.

BubbleContent

The bubble content wrapper, built on Base UI's useRender.

PropTypeDefaultDescription
renderReactElement | function—Render the content as another element, such as a link or button.
classNamestring—Additional classes to apply to the content element.

BubbleReactions

Displays overlapped reactions for a bubble. A <div>; every other div prop passes through.

PropTypeDefaultDescription
side"top" | "bottom""bottom"The side of the bubble to anchor the reactions.
align"start" | "end""end"The inline alignment of the reactions.
classNamestring—Additional classes to apply to the reaction row.

BubbleGroup

Groups consecutive bubbles from the same sender. It adds no props of its own — a <div> with data-slot="bubble-group" and className.

Accessibility

  • Bubble renders the presentational message surface. Conversation-level semantics — the list, the live region, the message count — belong to the container around it.
  • Reactions render as a row of emoji, and a screen reader reads each glyph with no context (+8 announces as "plus eight"). Group the row as a single image with a descriptive aria-label: role="img" also hides the individual glyphs, so no aria-hidden is needed.
  • When reactions are interactive, render real buttons instead, and give an icon-only button an aria-label.
  • An interactive bubble is a real <button> or <a> through render, named by its own text. It takes the global 2px :focus-visible outline from base.css — there is no ring glow, and nothing suppresses the outline (FOC-1 / FOC-6).
  • Variants signal role and tone with colour. Pair them with text, alignment or icons so meaning is never conveyed by colour alone — a destructive bubble keeps the error in the message text.
A row of glyphs is grouped as a single image.
Interactive reactions are buttons, not glyphs.
Failed to send: the recipient's mailbox is full.
ContractStates tested
Behaviourdefault, error
Accessibilityfocus-visible
Visualdefault, focus, error, dark

Do / Don't

Do
Use a Bubble for the message surface itself — chat text, a suggestion chip, a quoted reply — and let Message own the avatar, the name and the timestamp around it.
Don't
Reach for a Bubble as a generic card or callout, or leave a reactions row as bare emoji with no grouped aria-label.

Deviations

Upstream's file plus packages/ui/upstream/patches/bubble.patch. Every hunk:

  • FOC-1, FOC-6 — BubbleContent drops [button,a]:outline-none, [button,a]:focus-visible:border-ring, [button,a]:focus-visible:ring-3 and [button,a]:focus-visible:ring-ring/50. An interactive bubble takes base.css's one 2px :focus-visible outline like every other control; upstream's outline-none would have suppressed it outright, which is why the glow had to exist.
  • FOC-6 — bubbleReactionsVariants swaps upstream's RESTING ring-3 ring-card for outline-3 outline-card. FOC-6 is "never a box-shadow ring or glow anywhere" and it names Bubble by name; a Tailwind ring IS a box-shadow. The outline paints the identical 3px band in the identical token, outside the border box and following the radius, and the chip is a non-focusable <div>, so it can never collide with the focus outline.
  • A11Y-2 — audited, NO HUNK. Nothing in this file is a pointer target of its own: Bubble, BubbleGroup and BubbleReactions are <div>s, and BubbleContent becomes a control only when the call site renders one into it, at which point the control's own size is the call site's. bubble.test.tsx measures the interactive-bubble fixture against the floor.
  • A11Y-13 — the destructive bubble's ink becomes the family's -text role. Upstream writes text-destructive, which on its own /10 tint measures 3.987:1 in light — under the AA floor A11Y-1 enforces — where text-destructive-text reads 6.966:1 on the identical composite. The fill and every other part of the variant are untouched.
  • API-16 — "use client" is ADDED. Upstream ships none, and this file calls useRender from @base-ui/react/use-render, which calls React.useRef internally — under the react-server condition that hook is undefined, so a server component rendering it throws on import. tooling/verify-rsc-safety.mjs is what found it. API-16 puts the boundary at the lowest interactive leaf, and this file is it.
  • DOC-2 — import { cn } from "cn" becomes @vegastack/design; our npm layer owns cn.
  • DOC-1 — prettier reflow plus the registry-stamp.mjs provenance header the three-copy model needs; no decision of its own.

On this page