Bubble
A chat message bubble with variants, reactions, and start/end alignment. Pair it with Message for a full chat row.
Last updated August 8, 2026
Copied from shadcn/ui.
- Replaced physical left-3/right-3 with logical start-3/end-3 on BubbleReactions align variants
- Replaced [button]:text-left with [button]:text-start on BubbleContent
- Replaced bg-muted with bg-popover on BubbleReactions — this repo's --muted token is a 4% translucent overlay, which left the reaction pill nearly invisible
Overview
import {
Bubble,
BubbleContent,
BubbleGroup,
BubbleReactions,
} from "@/components/ui/bubble"
export function BubbleDemoExample() {
return (
<div className="flex w-full max-w-sm flex-col gap-8 py-4">
<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 https://ui.persian-labs.ir/r/bubble.jsonUsage
import { Bubble, BubbleContent, BubbleGroup } from "@/components/ui/bubble"
export function Example() {
return (
<BubbleGroup>
<Bubble align="start" variant="muted">
<BubbleContent>Hey, do you have a minute?</BubbleContent>
</Bubble>
<Bubble align="end">
<BubbleContent>Sure, what's up?</BubbleContent>
</Bubble>
</BubbleGroup>
)
}Variants
import {
Bubble,
BubbleContent,
BubbleReactions,
} from "@/components/ui/bubble"
export function BubbleVariantsExample() {
return (
<div className="flex w-full max-w-sm flex-col gap-12 py-4">
<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 and other content that
should not be framed. They are full width and can take the full
width of the container.
</BubbleContent>
</Bubble>
</div>
)
}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 BubbleAlignmentExample() {
return (
<div className="flex w-full max-w-sm flex-col gap-8 py-4">
<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
Use BubbleGroup to group consecutive bubbles from the same sender. Set align on each Bubble, not on the group.
import {
Bubble,
BubbleContent,
BubbleGroup,
BubbleReactions,
} from "@/components/ui/bubble"
export function BubbleGroupExample() {
return (
<div className="flex w-full max-w-sm flex-col gap-8 py-4">
<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
Turn a bubble into a link or button with the render prop on BubbleContent.
"use client"
import * as React from "react"
import {
Bubble,
BubbleContent,
BubbleGroup,
} from "@/components/ui/bubble"
export function BubbleLinkButtonExample() {
const [clicked, setClicked] = React.useState<string | null>(null)
return (
<div className="flex w-full max-w-sm flex-col gap-4 py-4">
<Bubble variant="muted">
<BubbleContent>How can I help you today?</BubbleContent>
</Bubble>
<BubbleGroup>
<Bubble variant="tinted" align="end">
<BubbleContent
render={<button onClick={() => setClicked("forgot password")} />}
>
I forgot my password
</BubbleContent>
</Bubble>
<Bubble variant="tinted" align="end">
<BubbleContent
render={
<button onClick={() => setClicked("help with subscription")} />
}
>
I need help with my subscription
</BubbleContent>
</Bubble>
<Bubble variant="tinted" align="end">
<BubbleContent
render={<button onClick={() => setClicked("talk to a human")} />}
>
Something else. Talk to a human.
</BubbleContent>
</Bubble>
</BubbleGroup>
{clicked && (
<p className="text-sm text-muted-foreground">
You clicked "{clicked}"
</p>
)}
</div>
)
}Reactions
Use BubbleReactions for reactions or quick action buttons. Use side and align to position the row.
"use client"
import * as React from "react"
import {
Bubble,
BubbleContent,
BubbleReactions,
} from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
export function BubbleReactionsExample() {
const [ran, setRan] = React.useState(false)
return (
<div className="flex w-full max-w-sm flex-col gap-12 py-4">
<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={() => setRan(true)}>
{ran ? "Running..." : "Yes, run it"}
</Button>
</BubbleReactions>
</Bubble>
</div>
)
}Show More / Collapsible
Long bubble content can be composed with Collapsible for a show more / 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,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
const previewText =
"The accessibility review found two focus states that were visually too subtle in dark mode."
const restText =
" 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."
export function BubbleCollapsibleExample() {
const [open, setOpen] = React.useState(false)
return (
<div className="flex w-full max-w-sm flex-col gap-8 py-4">
<Bubble variant="muted">
<BubbleContent>How can I help you today?</BubbleContent>
</Bubble>
<Bubble variant="muted" align="end">
<BubbleContent>
<Collapsible open={open} onOpenChange={setOpen}>
<p>{previewText}</p>
<CollapsibleContent className="mt-2">
<p>{restText}</p>
</CollapsibleContent>
<CollapsibleTrigger
render={
<Button
variant="link"
className="mt-1 gap-1 p-0 text-muted-foreground"
/>
}
>
{open ? "Show less" : "Show more"}
<ChevronDownIcon />
</CollapsibleTrigger>
</Collapsible>
</BubbleContent>
</Bubble>
</div>
)
}Tooltip
Wrap a reaction in Tooltip to reveal metadata on hover, such as a read receipt.
import { CheckIcon } from "lucide-react"
import {
Bubble,
BubbleContent,
BubbleReactions,
} from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
export function BubbleTooltipExample() {
return (
<TooltipProvider>
<div className="flex w-full max-w-sm flex-col gap-4 py-4">
<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 />
</Button>
}
/>
<TooltipContent>Read on Jan 5, 2026 at 4:32 PM</TooltipContent>
</Tooltip>
</BubbleReactions>
</Bubble>
</div>
</TooltipProvider>
)
}Popover
Pair a bubble with a Popover to surface more information on demand, such as a full error message.
import { InfoIcon } from "lucide-react"
import {
Bubble,
BubbleContent,
BubbleReactions,
} from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
export function BubblePopoverExample() {
return (
<div className="flex w-full max-w-sm flex-col gap-4 py-4">
<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 />
</Button>
}
/>
<PopoverContent>
<p className="text-sm font-medium">
Command failed with exit code 1
</p>
<p className="mt-1 text-sm text-muted-foreground">
ENOENT: no such file or directory, open pnpm-lock.yaml
</p>
</PopoverContent>
</Popover>
</BubbleReactions>
</Bubble>
</div>
)
}RTL
align="end" correctly sits on the visual left under RTL, and reactions mirror along with it, so a chat thread reads naturally either way.
import {
Bubble,
BubbleContent,
BubbleGroup,
BubbleReactions,
} from "@/components/ui/bubble"
export function BubbleRtlExample() {
return (
<BubbleGroup className="w-full max-w-sm">
<Bubble align="start" variant="muted">
<BubbleContent>سلام، یک دقیقه وقت داری؟</BubbleContent>
</Bubble>
<Bubble align="end">
<BubbleContent>البته، چی شده؟</BubbleContent>
<BubbleReactions role="img" aria-label="واکنش: پسندیدن">
<span>👍</span>
</BubbleReactions>
</Bubble>
</BubbleGroup>
)
}