- Accordion
- Alert
- Alert Dialog
- Ask User Questions
- Aspect Ratio
- Attachment
- Autocomplete
- Avatar
- Badge
- Bar List
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Chat Message
- Checkbox
- Checkbox Group
- Circular Progress
- Collapsible
- Combobox
- Command
- Context Menu
- Copy Button
- Currency Input
- Data Table
- Date Picker
- Dialog
- Drawer
- Dropdown Menu
- Empty
- Field
- Hover Card
- Input
- Input Group
- Input Message
- Input OTP
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Meter
- Multi Combobox
- Native Select
- Navigation Menu
- Number Field
- Pagination
- Phone Input
- Popover
- Progress
- Progress List
- Prompt
- Radio Group
- Resizable
- Scroll Area
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Sonner
- Spinner
- Stat
- Switch
- Table
- Tabs
- Textarea
- Thinking Indicator
- Thinking Steps
- Toggle
- Toggle Group
- Tooltip
- Typography
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 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 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 lower-level message surface. If you want a higher-level animated transcript entry that styles user and assistant turns for you, see Chat Message.
Installation
npx shadcn@latest add "https://ui.blode.co/r/styles/default/bubble"
Usage
import { Bubble, BubbleContent, BubbleGroup, BubbleReactions } from "@/components/ui/bubble";<Bubble align="end">
<BubbleContent>Hey there! what's up?</BubbleContent>
</Bubble>Examples
Variants
Use variant to change the emphasis of a bubble. The ghost variant is
unframed and full width, which works well for assistant text.
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 align="end" variant="secondary">
<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 aria-label="Reaction: thumbs up">
<span>👍</span>
</BubbleReactions>
</Bubble>
<Bubble align="end" variant="tinted">
<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 align="end" variant="destructive">
<BubbleContent>Or a destructive variant with a reaction.</BubbleContent>
<BubbleReactions aria-label="Reaction: fire">
<span>🔥</span>
</BubbleReactions>
</Bubble>
<Bubble variant="ghost">
<BubbleContent className="flex flex-col gap-3">
<p>Ghost bubbles work for assistant text and other content that should not be framed.</p>
<p>
This is perfect for assistant messages that should not have a frame and can take the
full width of the container.
</p>
<p>Ghost bubbles are full width and can take the full width of the container.</p>
</BubbleContent>
</Bubble>
</div>
);
}Alignment
Use align="end" for the current user's messages and align="start" (the
default) for everyone else.
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>
);
}Group
Wrap consecutive messages from the same sender in a BubbleGroup to tighten the
spacing between them.
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 align="start" aria-label="Reactions: eyes">
<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>
);
}Reactions
Add a BubbleReactions element to overlay reactions on a bubble. Control the
placement with side and align.
"use client";
import { toast } from "sonner";
import { Bubble, BubbleContent, BubbleReactions } from "@/components/ui/bubble";
import { Button } from "@/components/ui/button";
export function BubbleReactionsExample() {
return (
<div className="flex w-full max-w-sm flex-col gap-12 py-12">
<Bubble align="end" variant="muted">
<BubbleContent>I don't need tests, I know my code works.</BubbleContent>
<BubbleReactions align="start" 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 aria-label="Reactions: eyes, rocket, and 2 more">
<span>👀</span>
<span>🚀</span>
<span>+2</span>
</BubbleReactions>
</Bubble>
<Bubble align="end" variant="default">
<BubbleContent>Tests passed on the first try. All 142 of them. Looking good!</BubbleContent>
<BubbleReactions
align="start"
aria-label="Reactions: party popper, clapping hands"
side="top"
>
<span>🎉</span>
<span>👏</span>
</BubbleReactions>
</Bubble>
<Bubble variant="destructive">
<BubbleContent>Are you sure I can run this command?</BubbleContent>
<BubbleReactions>
<Button
onClick={() => toast.success("You clicked yes, running command...")}
size="xs"
variant="ghost"
>
Yes, run it
</Button>
</BubbleReactions>
</Bubble>
</div>
);
}Tooltip
Reactions can hold interactive content, such as a tooltip trigger.
import { CheckIcon } from "blode-icons-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 BubbleTooltip() {
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 size="icon-xs" variant="ghost" />}>
<CheckIcon />
</TooltipTrigger>
<TooltipContent>Read on Jan 5, 2026 at 4:32 PM</TooltipContent>
</Tooltip>
</BubbleReactions>
</Bubble>
</div>
);
}Popover
Use a popover inside a reaction to surface extra detail, like an error message.
import { InfoIcon } from "blode-icons-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 BubblePopover() {
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
aria-label="Show error details"
className="aria-expanded:text-destructive"
size="icon-xs"
variant="ghost"
/>
}
>
<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>
);
}Collapsible
Collapse long messages behind a show more / show less toggle.
"use client";
import { ChevronDownIcon } from "blode-icons-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 align="end" variant="muted">
<BubbleContent className="whitespace-pre-line">
<Collapsible onOpenChange={setOpen} open={open}>
<div>{open || !isLong ? text : preview}</div>
{isLong ? (
<CollapsibleTrigger
render={<Button className="gap-1 p-0 text-muted-foreground" variant="link" />}
>
{open ? "Show less" : "Show more"}
<ChevronDownIcon
className="group-data-[panel-open]/button:rotate-180"
data-icon="inline-end"
/>
</CollapsibleTrigger>
) : null}
</Collapsible>
</BubbleContent>
</Bubble>
</div>
);
}Link buttons
Render a bubble's content as a button to build quick-reply prompts.
"use client";
import { toast } from "sonner";
import { Bubble, BubbleContent, BubbleGroup } from "@/components/ui/bubble";
export function BubbleLinkButton() {
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 align="end" variant="tinted">
<BubbleContent
render={
<button
aria-label="I forgot my password"
onClick={() => toast("You clicked forgot password")}
type="button"
/>
}
>
I forgot my password
</BubbleContent>
</Bubble>
<Bubble align="end" variant="tinted">
<BubbleContent
render={
<button
aria-label="I need help with my subscription"
onClick={() => toast("You clicked help with subscription")}
type="button"
/>
}
>
I need help with my subscription
</BubbleContent>
</Bubble>
<Bubble align="end" variant="tinted">
<BubbleContent
render={
<button
aria-label="Something else. Talk to a human."
onClick={() => toast("You clicked something else. Talk to a human.")}
type="button"
/>
}
>
Something else. Talk to a human.
</BubbleContent>
</Bubble>
</BubbleGroup>
</div>
);
}API Reference
Bubble is composed of BubbleGroup, Bubble, BubbleContent, and
BubbleReactions.
Bubbleacceptsvariant(default,secondary,muted,tinted,outline,ghost,destructive) andalign(start,end).BubbleContentaccepts arenderprop to change the underlying element (for example abuttonora).BubbleReactionsacceptsside(top,bottom) andalign(start,end).