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 @herocn/bubbleimport { 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="soft" align="end"> <BubbleContent> This one is soft. It 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. |
soft | A subtle primary-soft 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 { Bubble, BubbleContent, BubbleGroup,} from "@/components/ui/bubble";import { toast } from "@/components/ui/toast";
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="soft" align="end"> <BubbleContent render={ <button type="button" onClick={() => toast.add({ title: "You clicked forgot password" }) } /> } > I forgot my password </BubbleContent> </Bubble> <Bubble variant="soft" align="end"> <BubbleContent render={ <button type="button" onClick={() => toast.add({ title: "You clicked help with subscription" }) } /> } > I need help with my subscription </BubbleContent> </Bubble> <Bubble variant="soft" align="end"> <BubbleContent render={ <button type="button" onClick={() => toast.add({ title: "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 { Bubble, BubbleContent, BubbleReactions,} from "@/components/ui/bubble";import { Button } from "@/components/ui/button";import { toast } from "@/components/ui/toast";
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.add({ title: "You clicked yes, running command...", type: "success", }) } > 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" | "soft" | "outline" | "ghost" | "destructive" | "default" |
| align | "start" | "end" | "start" |
| className | string | — |
Additional classes to apply to the root element. | ||
The bubble content wrapper.
| Prop | Type | Default |
|---|---|---|
| render | ReactElement | function | — |
Render the content as a different element such as a link. | ||
| className | string | — |
Additional classes to apply to the content element. | ||
Displays overlapped reactions for a bubble.
| Prop | Type | Default |
|---|---|---|
| side | "top" | "bottom" | "bottom" |
| align | "start" | "end" | "end" |
| className | string | — |
Additional classes to apply to the reaction row. | ||
Groups consecutive bubbles from the same sender.
| Prop | Type | Default |
|---|---|---|
| className | string | — |
Additional classes to apply to the group root. | ||