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>
)
}Installation
npx shadcn@latest add @fulldev/bubble
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>
Composition
Use the following composition to build a bubble:
Bubble
├── BubbleContent
└── BubbleReactions
BubbleGroup
├── Bubble
│ └── BubbleContent
└── Bubble
└── BubbleContent
Variants
Use variant to change the visual treatment of the bubble.
import { Bubble, BubbleContent, BubbleReactions } from "@/components/ui/bubble"
import { Markdown } from "@/components/markdown"
export function BubbleVariantsDemo() {
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>
<Markdown>{`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.
`}</Markdown>
</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. |
Alignment
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 BubbleAlignmentDemo() {
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. |
Note: When building chat interfaces, you probably want to use alignment on the Message component itself, not the Bubble component. You can use the role prop on the Message component to automatically align the bubble to the start or end of the conversation.
Bubble Group
Use BubbleGroup to group consecutive bubbles from the same sender.
BubbleGroup
├── Bubble
│ └── BubbleContent
└── Bubble
└── BubbleContent
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>
)
}Links and Buttons
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 onClick={() => toast("You clicked forgot password")} />
}
>
I forgot my password
</BubbleContent>
</Bubble>
<Bubble variant="tinted" align="end">
<BubbleContent
render={
<button
onClick={() => toast("You clicked help with subscription")}
/>
}
>
I need help with my subscription
</BubbleContent>
</Bubble>
<Bubble variant="tinted" align="end">
<BubbleContent
render={
<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>
)
}
Reactions
Use BubbleReactions for bubble reactions.
"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>
)
}Show More / Collapsible
Long bubble content can be composed with Collapsible to allow for a show more or show less interaction.
"use client"
import * as React from "react"
import { ChevronDownIcon } from "lucide-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"
className="group-data-panel-open/button:rotate-180"
/>
</CollapsibleTrigger>
) : null}
</Collapsible>
</BubbleContent>
</Bubble>
</div>
)
}Tooltip
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>
)
}Popover
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>
)
}Accessibility
Bubble renders the presentational message surface.
Labeling Reactions
Reactions render as a row of emoji.
<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>
Interactive Bubbles
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.
<Bubble variant="muted" align="end">
<BubbleContent render={<button type="button" onClick={onReply} />}>
I forgot my password
</BubbleContent>
</Bubble>
Meaning Beyond Color
Bubble variants signal role and tone with color.
API Reference
Source: react/src/components/ui/bubble.tsx
Bubble
The root bubble wrapper.
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "secondary" | "muted" | "tinted" | "outline" | "ghost" | "destructive" | "default" | The bubble visual treatment. |
align | "start" | "end" | "start" | The inline alignment of the bubble. |
className | string | - | Additional classes to apply to the root element. |
BubbleContent
The bubble content wrapper.
| Prop | Type | Default | Description |
|---|---|---|---|
render | ReactElement | function | - | Render the content as a different element such as a link. |
className | string | - | Additional classes to apply to the content element. |
BubbleReactions
Displays overlapped reactions for a bubble.
| Prop | Type | Default | Description |
|---|---|---|---|
side | "top" | "bottom" | "bottom" | The side of the bubble to anchor the reactions. |
align | "start" | "end" | "end" | The inline alignment of the reactions. |
className | string | - | Additional classes to apply to the reaction row. |
BubbleGroup
Groups consecutive bubbles from the same sender.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Additional classes to apply to the group root. |