Chat
Displays conversational content in a message bubble. Supports variants, alignment, grouping, reactions, and collapsible content.
Playground
Message, which wraps a Bubble to build a full chat interface.import {
Bubble,
BubbleContent,
} from "@workspace/ui/components/bubble"
<Bubble variant="muted">
<BubbleContent>Did you remove the stale route?</BubbleContent>
</Bubble>
<Bubble align="end">
<BubbleContent>Yes โ I checked the registry output and removed it.</BubbleContent>
</Bubble>BubbleContent through the parent, so the variant lives on Bubble while the color lands on the inner surface. ghost drops the frame, padding, and max width so assistant text can span the full row.<Bubble>
<BubbleContent>This is the default primary bubble.</BubbleContent>
</Bubble>
<Bubble variant="secondary" align="end">
<BubbleContent>This is the secondary variant.</BubbleContent>
</Bubble>
<Bubble variant="muted">
<BubbleContent>This one is muted.</BubbleContent>
<BubbleReactions role="img" aria-label="Reaction: thumbs up">
<span>๐</span>
</BubbleReactions>
</Bubble>
<Bubble variant="tinted" align="end">
<BubbleContent>This one is tinted.</BubbleContent>
</Bubble>
<Bubble variant="outline">
<BubbleContent>We can also use an outlined variant.</BubbleContent>
</Bubble>
<Bubble variant="destructive" align="end">
<BubbleContent>Or a destructive variant with a reaction.</BubbleContent>
</Bubble>
<Bubble variant="ghost">
<BubbleContent>Ghost bubbles work for unframed assistant text.</BubbleContent>
</Bubble>| Variant | Description |
|---|---|
| default | A strong primary bubble, usually for the current user. |
| secondary | The standard neutral bubble for conversation content. |
| muted | A lower-emphasis bubble for quiet supporting content. |
| tinted | A subtle primary-tinted bubble. |
| outline | A bordered bubble for secondary or rich content. |
| ghost | Unframed content for assistant text or rich content. Removes the max width so it spans the full row. |
| destructive | A destructive bubble for an error or a failed action. |
align pushes the bubble to the start or the end of the conversation โ the incoming/outgoing split of a thread. It uses logical properties, so the sides mirror under dir="rtl". When you build a chat with Message, set alignment on the message row instead: the bubble reads the row's data-align and follows it.<Bubble variant="muted"> <BubbleContent>This bubble is aligned to the start. This is the default.</BubbleContent> </Bubble> <Bubble align="end"> <BubbleContent>This bubble is aligned to the end. Use this for user messages.</BubbleContent> </Bubble>
BubbleGroup stacks consecutive bubbles from the same sender with a tighter gap. Alignment stays on each Bubble โ the group is only the column that holds them.<BubbleGroup>
<Bubble align="end">
<BubbleContent>You tell me!</BubbleContent>
</Bubble>
<Bubble align="end">
<BubbleContent>It worked yesterday. You broke it!</BubbleContent>
</Bubble>
<Bubble align="end">
<BubbleContent>Find the bug and fix it.</BubbleContent>
<BubbleReactions align="start" role="img" aria-label="Reaction: eyes">
<span>๐</span>
</BubbleReactions>
</Bubble>
</BubbleGroup>asChild on BubbleContent renders the surface as whatever you pass in. Use it for reply suggestions and link previews: the bubble picks up a hover tint and a focus ring for button and a children automatically.<Bubble variant="tinted" align="end">
<BubbleContent asChild>
<button type="button" onClick={() => toast("You clicked forgot password")}>
I forgot my password
</button>
</BubbleContent>
</Bubble>BubbleReactions is an absolutely positioned row anchored to a bubble edge: side picks the top or bottom edge and align the inline side. It holds emoji or quick-action buttons. Because the row overlaps the edge, leave extra vertical gap between bubbles โ these examples use gap-10.<Bubble variant="muted" align="end">
<BubbleContent>I don't need tests, I know my code works.</BubbleContent>
<BubbleReactions align="start" role="img" aria-label="Reactions: thumbs up, surprised">
<span>๐</span>
<span>๐ฎ</span>
</BubbleReactions>
</Bubble>
<Bubble align="end">
<BubbleContent>Tests passed on the first try. All 142 of them.</BubbleContent>
<BubbleReactions side="top" align="start" role="img" aria-label="Reactions: party popper, clapping hands">
<span>๐</span>
<span>๐</span>
</BubbleReactions>
</Bubble>
<Bubble variant="destructive">
<BubbleContent>Are you sure I can run this command?</BubbleContent>
<BubbleReactions>
<Button variant="ghost" size="xs" onClick={() => toast.success("Running commandโฆ")}>
Yes, run it
</Button>
</BubbleReactions>
</Bubble>Collapsible for a show more/show less interaction. Keep the trigger inside BubbleContent so it inherits the bubble color, and render a truncated preview yourself rather than clipping with CSS โ the bubble grows to whatever it holds.const [open, setOpen] = React.useState(false)
<Bubble variant="muted" align="end">
<BubbleContent className="whitespace-pre-line">
<Collapsible open={open} onOpenChange={setOpen}>
<div>{open ? text : preview}</div>
<CollapsibleTrigger asChild>
<Button variant="link" className="gap-1 p-0 text-muted-foreground">
{open ? "Show less" : "Show more"}
<CaretDownIcon
data-icon="inline-end"
className="transition-transform group-data-[state=open]/button:rotate-180"
/>
</Button>
</CollapsibleTrigger>
</Collapsible>
</BubbleContent>
</Bubble>Tooltip into BubbleReactions to reveal a read receipt or a timestamp on hover โ clear the row padding with className="p-0" when it holds a single icon button.<Bubble align="end">
<BubbleContent>Yes, removed it from the registry.</BubbleContent>
<BubbleReactions className="p-0">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon-xs">
<CheckIcon />
<span className="sr-only">Read receipt</span>
</Button>
</TooltipTrigger>
<TooltipContent>Read on Jan 5, 2026 at 4:32 PM</TooltipContent>
</Tooltip>
</BubbleReactions>
</Bubble>Popover to surface detail on demand โ the full stack trace behind a failed action, without spending the thread on it.<Bubble variant="destructive">
<BubbleContent>Failed to run the command.</BubbleContent>
<BubbleReactions>
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
aria-label="Show error details"
className="aria-expanded:text-destructive"
>
<InfoIcon />
</Button>
</PopoverTrigger>
<PopoverContent>
<PopoverHeader>
<PopoverTitle className="text-sm">Command failed with exit code 1</PopoverTitle>
<PopoverDescription className="text-sm">
ENOENT: no such file or directory, open yarn.lock
</PopoverDescription>
</PopoverHeader>
</PopoverContent>
</Popover>
</BubbleReactions>
</Bubble>+8is announced as "plus eight", so group the row as a single image with role="img" and a descriptive aria-label; that also hides the individual emoji, so no aria-hidden is needed. When the reactions are interactive, render real buttons and label the icon-only ones. A clickable bubble must be a real button or a via asChild โ the bubble text becomes its accessible name, and the focus ring already ships. Finally, variants signal tone with color alone: keep the error context in the words of a destructive bubble.<BubbleReactions role="img" aria-label="Reactions: thumbs up, fire, and 8 more">
<span>๐</span>
<span>๐ฅ</span>
<span>+8</span>
</BubbleReactions>
<BubbleReactions>
<Button aria-label="Thumbs up" variant="secondary" size="icon-xs">
<ThumbsUpIcon />
</Button>
</BubbleReactions>
<Bubble variant="muted" align="end">
<BubbleContent asChild>
<button type="button" onClick={onReply}>
I forgot my password
</button>
</BubbleContent>
</Bubble>div wrappers. Bubble mirrors its props to data-variant and data-align, which the content and reaction rows read to style and position themselves. All native div props pass through.| Part | Prop | Default |
|---|---|---|
| Bubble | variant?: "default" | "secondary" | "muted" | "tinted" | "outline" | "ghost" | "destructive" | "default" |
| Bubble | align?: "start" | "end" | "start" |
| Bubble | className?: string | โ |
| BubbleContent | asChild?: boolean | false |
| BubbleContent | className?: string | โ |
| BubbleReactions | side?: "top" | "bottom" | "bottom" |
| BubbleReactions | align?: "start" | "end" | "end" |
| BubbleReactions | className?: string | โ |
| BubbleGroup | className?: string | โ |
Tokens used
15 design tokens consumed by the Bubble component.
--primary
Primary
--primary-foreground
Primary Foreground
--secondary
Secondary
--secondary-foreground
Secondary Foreground
--muted
Muted
--foreground
Foreground
--background
Background
--card
Card
--border
Border
--input
Input
--destructive
Destructive
--ring
Ring
--radius-xl
Radius Extra Large
--text-sm
Text Small (14px)
--spacing
Spacing unit (4px)