- Accordion
- Alert
- Alert Dialog
- Attachment
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Checkbox
- Collapsible
- Command
- Context Menu
- Data Table
- Dialog
- Drawer
- Dropdown Menu
- Empty
- Field
- Hover Card
- Input
- Input Group
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Pagination
- Popover
- Progress
- Questionnaire
- Radio Group
- Scroll Area
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Sonner
- Spinner
- Switch
- Table
- Tabs
- Textarea
- Toggle
- Toggle Group
- Tooltip
Displays conversational content in a message bubble. Supports variants, alignment, grouping, reactions, and collapsible content.
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>
)
}Bubble is the framed surface of a chat message: text, short structured output, quoted replies, suggestions and reactions. Avatars, names, timestamps and message actions belong to Message.
Installation#
pnpm dlx shadcn@latest add https://ui.ikramhasan.com/r/bubble.json
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#
Bubble
├── BubbleContent
└── BubbleReactionsUse BubbleGroup for consecutive bubbles from the same sender:
BubbleGroup
├── Bubble
│ └── BubbleContent
└── Bubble
└── BubbleContentExamples#
Variants#
Ghost bubbles work for assistant text, markdown, and other content that should not be framed.
They drop the max width, so a reply can take the full width of the container. You can also render code in it.
import { Bubble, BubbleContent, BubbleReactions } from "@/components/ui/bubble"
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>
<div className="flex flex-col gap-2">
<p>
Ghost bubbles work for assistant text, <strong>markdown</strong>,
and other content that should not be framed.
</p>
<p>
They drop the max width, so a reply can take the full width of the
container. You can also render{" "}
<code className="font-mono text-[13px]">code</code> in it.
</p>
</div>
</BubbleContent>
</Bubble>
</div>
)
}| Variant | Description |
|---|---|
default | The person's own turn: a raised ink bubble (foreground fill). |
secondary | A raised white bubble, the secondary Button skin. |
muted | A flat muted bubble for the other side of the conversation. |
tinted | A soft primary tint. |
outline | A hairline frame on background. |
ghost | Unframed, full-width content for assistant text and rich output. |
destructive | A destructive tint for errors and failed actions. |
A bubble sizes to its content, up to 80% of the row. ghost drops the limit so a reply can use the full width.
Alignment#
Use align to put a bubble at the start or end of the conversation. Inside a Message, set align on the message instead.
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>
)
}Bubble Group#
BubbleGroup stacks consecutive bubbles 4px apart and tucks their corners on the sender's side, so the run reads as one turn. Set align on each Bubble, not on the group.
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#
Render BubbleContent as a link or button with render. Interactive bubbles get a hover state and the focus ring.
"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>
)
}Reactions#
BubbleReactions pins a small raised chip over the bubble's edge. Use side and align to place it, and leave room between rows for the overlap.
"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>
)
}Collapsible#
Compose long content with Collapsible for a show more / show less toggle.
"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#
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#
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#
- Give a row of emoji reactions
role="img"and anaria-label("Reactions: thumbs up, fire, and 8 more"), so it is announced once instead of glyph by glyph. Interactive reactions are buttons with their own labels. - Clickable bubbles must be real
<button>or<a>elements viarender; the bubble text is the accessible name. - Variants signal tone with color, so keep the meaning in the text too (say what failed in a
destructivebubble).
API Reference#
| Part | Element | Props |
|---|---|---|
Bubble | div | variant: default · secondary · muted · tinted · outline · ghost · destructive (default default). align: start · end (default start). |
BubbleContent | div | render to use a different element, such as a link or button. 36px for one line, rounded-2xl, text 14px from the edge. |
BubbleReactions | div | side: top · bottom (default bottom). align: start · end (default end). |
BubbleGroup | div | Groups consecutive bubbles from the same sender. |