import {
Bubble,
BubbleContent,
BubbleGroup,
BubbleReactions,
} from "@/components/ui/bubble";
export function BubbleDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-8 py-12">
<Bubble align="end">
<BubbleContent>Hey there! what's up?</BubbleContent>
</Bubble>
<BubbleGroup>
<Bubble variant="muted">
<BubbleContent>Hey! Want to see chat bubbles?</BubbleContent>
</Bubble>
<Bubble variant="muted">
<BubbleContent>
I can group messages, switch sides, and keep the whole thread easy
to scan.
</BubbleContent>
<BubbleReactions role="img" aria-label="Reaction: thumbs up">
<span>👍</span>
</BubbleReactions>
</Bubble>
</BubbleGroup>
<Bubble align="end">
<BubbleContent>Sure. Hit me with your best demo.</BubbleContent>
</Bubble>
<Bubble variant="muted">
<BubbleContent>
Yes. You are reading a demo that is demoing itself. Very meta. Very
on-brand.
</BubbleContent>
<BubbleReactions
role="img"
aria-label="Reactions: thumbs up, fire, eyes, and 2 more"
>
<span>👍</span>
<span>🔥</span>
<span>👀</span>
<span>+2</span>
</BubbleReactions>
</Bubble>
</div>
);
}
pnpm dlx shadcn@latest add https://herocn.dev/r/bubble.jsonimport { 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>Use the following composition to build a bubble:
Bubble
├── BubbleContent
└── BubbleReactionsUse BubbleGroup to group consecutive bubbles from the same sender:
BubbleGroup
├── Bubble
│ └── BubbleContent
└── Bubble
└── BubbleContentUse variant to change the visual treatment of the bubble.
import {
Bubble,
BubbleContent,
BubbleReactions,
} from "@/components/ui/bubble";
export function BubbleVariants() {
return (
<div className="flex w-full max-w-sm flex-col gap-12 py-12">
<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. It uses a lower emphasis color for the chat bubble.
</BubbleContent>
<BubbleReactions role="img" aria-label="Reaction: thumbs up">
<span>👍</span>
</BubbleReactions>
</Bubble>
<Bubble variant="tinted" align="end">
<BubbleContent>
This one is tinted. The tint is a softer color derived from the
primary color.
</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>
<BubbleReactions role="img" aria-label="Reaction: fire">
<span>🔥</span>
</BubbleReactions>
</Bubble>
<Bubble variant="ghost">
<BubbleContent>
Ghost bubbles work for assistant text, **markdown**, and other content
that should not be framed.
<br />
<br />
This is perfect for assistant messages that should not have a frame
and can take the full width of the container.
<br />
<br />
Ghost bubbles are full width and can take the full width of the
container.
</BubbleContent>
</Bubble>
</div>
);
}
| 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. |
destructive | A 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.
Use align on Bubble to align the bubble to the start or end of the conversation.
import { Bubble, BubbleContent } from "@/components/ui/bubble";
export function BubbleAlignment() {
return (
<div className="flex w-full max-w-sm flex-col gap-8 py-12">
<Bubble variant="muted">
<BubbleContent>
This bubble is aligned to the start. This is the default alignment.
</BubbleContent>
</Bubble>
<Bubble align="end">
<BubbleContent>
This bubble is aligned to the end. Use this for user messages.
</BubbleContent>
</Bubble>
</div>
);
}
| align | Description |
|---|---|
start | Align the bubble to the start of the conversation. |
end | Align the bubble to the end of the conversation. |
Use BubbleGroup to group consecutive bubbles from the same sender.
import {
Bubble,
BubbleContent,
BubbleGroup,
BubbleReactions,
} from "@/components/ui/bubble";
export function BubbleGroupDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-8 py-12">
<Bubble variant="muted">
<BubbleContent>Can you tell me what's the issue?</BubbleContent>
</Bubble>
<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 aria-label="Reactions: eyes" align="start">
<span>👀</span>
</BubbleReactions>
</Bubble>
</BubbleGroup>
<Bubble variant="muted">
<BubbleContent>
Want me to diff yesterday's you against today's you?
It's a bit embarrassing.
</BubbleContent>
</Bubble>
</div>
);
}
You can turn a bubble into a link or button by using the render prop on BubbleContent.
"use client";
import { toast } from "sonner";
import {
Bubble,
BubbleContent,
BubbleGroup,
} from "@/components/ui/bubble";
export function BubbleLinkButtonDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-8 py-12">
<Bubble variant="muted">
<BubbleContent>How can I help you today?</BubbleContent>
</Bubble>
<BubbleGroup>
<Bubble variant="tinted" align="end">
<BubbleContent
render={
<button
type="button"
onClick={() => toast("You clicked forgot password")}
/>
}
>
I forgot my password
</BubbleContent>
</Bubble>
<Bubble variant="tinted" align="end">
<BubbleContent
render={
<button
type="button"
onClick={() => toast("You clicked help with subscription")}
/>
}
>
I need help with my subscription
</BubbleContent>
</Bubble>
<Bubble variant="tinted" align="end">
<BubbleContent
render={
<button
type="button"
onClick={() =>
toast("You clicked something else. Talk to a human.")
}
/>
}
>
Something else. Talk to a human.
</BubbleContent>
</Bubble>
</BubbleGroup>
</div>
);
}
import { Bubble, BubbleContent } from "@/components/ui/bubble"
export function BubbleLinkDemo() {
return (
<Bubble variant="muted">
<BubbleContent render={<button />}>Click here</BubbleContent>
</Bubble>
)
}Use BubbleReactions for bubble reactions. You can use it to display reactions or quick action buttons. Use side and align to position the row.
"use client";
import { toast } from "sonner";
import {
Bubble,
BubbleContent,
BubbleReactions,
} from "@/components/ui/bubble";
import { Button } from "@/components/ui/button";
export function BubbleReactionsDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-12 py-12">
<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 variant="muted">
<BubbleContent>
Bold. Fine I'll add some tests. I'll let you know when
they're done.
</BubbleContent>
<BubbleReactions
role="img"
aria-label="Reactions: eyes, rocket, and 2 more"
>
<span>👀</span>
<span>🚀</span>
<span>+2</span>
</BubbleReactions>
</Bubble>
<Bubble variant="default" align="end">
<BubbleContent>
Tests passed on the first try. All 142 of them. Looking good!
</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("You clicked yes, running command...")}
>
Yes, run it
</Button>
</BubbleReactions>
</Bubble>
</div>
);
}
Long bubble content can be composed with Collapsible to allow for a show more or show less interaction.
"use client";
import { ChevronDownIcon } from "lucide-react";
import * as React from "react";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
const text = `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 controls inside a layered surface.
The dialog and drawer are fine. The menu needs the hover and focus tokens split so keyboard focus stays visible when the pointer is not involved.
I also recommend keeping the change in the style file instead of the primitive so the other themes can choose their own focus treatment later.`;
const previewLength = 180;
export function BubbleCollapsible() {
const [open, setOpen] = React.useState(false);
const isLong = text.length > previewLength;
const preview = `${text.slice(0, previewLength)}...`;
return (
<div className="flex w-full max-w-sm flex-col gap-8 py-12">
<Bubble variant="muted">
<BubbleContent>How can I help you today?</BubbleContent>
</Bubble>
<Bubble variant="muted" align="end">
<BubbleContent className="whitespace-pre-line">
<Collapsible open={open} onOpenChange={setOpen}>
<div>{open || !isLong ? text : preview}</div>
{isLong ? (
<CollapsibleTrigger
render={
<Button
variant="link"
className="gap-1 p-0 text-muted-foreground"
/>
}
>
{open ? "Show less" : "Show more"}
<ChevronDownIcon data-icon="inline-end" />
</CollapsibleTrigger>
) : null}
</Collapsible>
</BubbleContent>
</Bubble>
</div>
);
}
Wrap a bubble in a Tooltip to reveal metadata on hover, such as when a message was read.
import { CheckIcon } from "lucide-react";
import {
Bubble,
BubbleContent,
BubbleReactions,
} from "@/components/ui/bubble";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
export function BubbleTooltipDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-4 py-12">
<Bubble variant="secondary">
<BubbleContent>Did you remove the stale route?</BubbleContent>
</Bubble>
<Bubble align="end">
<BubbleContent>Yes, removed it from the registry.</BubbleContent>
<BubbleReactions>
<Tooltip>
<TooltipTrigger render={<Button variant="ghost" size="icon-xs" />}>
<CheckIcon />
</TooltipTrigger>
<TooltipContent>Read on Jan 5, 2026 at 4:32 PM</TooltipContent>
</Tooltip>
</BubbleReactions>
</Bubble>
</div>
);
}
Pair a bubble with a Popover to surface more information on demand, such as the full error message for a failed action.
import { InfoIcon } from "lucide-react";
import {
Bubble,
BubbleContent,
BubbleReactions,
} from "@/components/ui/bubble";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
export function BubblePopoverDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-4 py-12">
<Bubble align="end">
<BubbleContent>Run the build script.</BubbleContent>
</Bubble>
<Bubble variant="destructive">
<BubbleContent>Failed to run the command.</BubbleContent>
<BubbleReactions>
<Popover>
<PopoverTrigger
render={
<Button
variant="ghost"
size="icon-xs"
aria-label="Show error details"
className="aria-expanded:text-destructive"
/>
}
>
<InfoIcon />
</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 pnpm-lock.yaml
</PopoverDescription>
</PopoverHeader>
</PopoverContent>
</Popover>
</BubbleReactions>
</Bubble>
</div>
);
}
Bubble renders the presentational message surface. Keep conversation-level semantics on the surrounding container and follow the guidelines below.
Reactions render as a row of emoji. A screen reader reads each glyph with no context, and counters like +8 are announced as "plus eight". Group the row as a single image with a descriptive aria-label so it announces once. role="img" also hides the individual emoji from assistive tech, so no aria-hidden is needed.
<BubbleReactions role="img" aria-label="Reactions: thumbs up, fire, and 8 more">
<span>👍</span>
<span>🔥</span>
<span>+8</span>
</BubbleReactions>When reactions are interactive, render buttons instead and give icon-only buttons an aria-label.
<BubbleReactions>
<Button aria-label="Thumbs up" variant="secondary" size="icon-xs">
<ThumbsUpIcon />
</Button>
</BubbleReactions>When a bubble is clickable, render it as a real <button> or <a> with the render prop so it is focusable and exposes the correct role. BubbleContent ships a visible focus ring for interactive elements, and the accessible name comes from the bubble text. No extra label is needed.
<Bubble variant="muted" align="end">
<BubbleContent render={<button type="button" onClick={onReply} />}>
I forgot my password
</BubbleContent>
</Bubble>Bubble variants signal role and tone with color. Pair them with text, alignment, or icons so meaning is not conveyed by color alone. For a destructive bubble, keep the error context in the message text rather than relying on the color treatment.
The root bubble wrapper.
| Prop | Type | Default |
|---|---|---|
| variant | "default" | "secondary" | "muted" | "tinted" | "outline" | "ghost" | "destructive" | "default" |
| align | "start" | "end" | "start" |
| className | string | — |
The bubble content wrapper.
| Prop | Type | Default |
|---|---|---|
| render | ReactElement | function | — |
| className | string | — |
Displays overlapped reactions for a bubble.
| Prop | Type | Default |
|---|---|---|
| side | "top" | "bottom" | "bottom" |
| align | "start" | "end" | "end" |
| className | string | — |
Groups consecutive bubbles from the same sender.
| Prop | Type | Default |
|---|---|---|
| className | string | — |