Bubble
Chat message bubble with seven color variants, start/end alignment, and optional reaction pills.
Examples
Variants
Seven color variants. The variant paints the nested BubbleContent, so the color follows the author regardless of alignment.
import { Bubble, BubbleContent, BubbleReactions } from "@codefast/ui/bubble";
const ghostText = `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.`;
function Paragraphs({ text }: { text: string }) {
return (
<>
{text.split(/\n{2,}/).map((paragraph) => (
<p key={paragraph} className="whitespace-pre-wrap">
{paragraph}
</p>
))}
</>
);
}
export function BubbleVariants() {
return (
<div className="flex w-full max-w-sm flex-col gap-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" role="img">
<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" role="img">
<span>🔥</span>
</BubbleReactions>
</Bubble>
<Bubble variant="ghost">
<BubbleContent>
<Paragraphs text={ghostText} />
</BubbleContent>
</Bubble>
</div>
);
}
Alignment
align defaults to start; switch to end for the current user's own messages.
import { Bubble, BubbleContent } from "@codefast/ui/bubble";
export function BubbleAlignment() {
return (
<div className="flex w-full max-w-sm flex-col gap-8">
<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
BubbleGroup stacks consecutive bubbles from one author with tight spacing so the thread scans cleanly.
import { Bubble, BubbleContent, BubbleGroup, BubbleReactions } from "@codefast/ui/bubble";
export function BubbleGroupDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-8">
<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
BubbleReactions overlaps a reaction pill on a bubble corner. Use side (top/bottom) and align (start/end) to place it.
import { Bubble, BubbleContent, BubbleReactions } from "@codefast/ui/bubble";
import { Button } from "@codefast/ui/button";
import { toast } from "@codefast/ui/sonner";
export function BubbleReactionsDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-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" role="img">
<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" role="img">
<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" role="img" 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>
);
}
Collapsible
Fold a long message behind a Show more toggle by nesting a Collapsible inside BubbleContent.
import { Bubble, BubbleContent } from "@codefast/ui/bubble";
import { Button } from "@codefast/ui/button";
import { Collapsible, CollapsibleTrigger } from "@codefast/ui/collapsible";
import { ChevronDownIcon } from "lucide-react";
import { useState } from "react";
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] = 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">
<Bubble variant="muted">
<BubbleContent>How can I help you today?</BubbleContent>
</Bubble>
<Bubble align="end" variant="muted">
<BubbleContent className="whitespace-pre-line">
<Collapsible open={open} onOpenChange={setOpen}>
<div>{open || !isLong ? text : preview}</div>
{isLong ? (
<CollapsibleTrigger asChild>
<Button className="gap-1 p-0 text-ui-muted" variant="link">
{open ? "Show less" : "Show more"}
<ChevronDownIcon className="group-aria-expanded/button:rotate-180" data-icon="inline-end" />
</Button>
</CollapsibleTrigger>
) : null}
</Collapsible>
</BubbleContent>
</Bubble>
</div>
);
}
Rich text
Bubbles hold arbitrary markup — render assistant replies as paragraphs, ideal for the ghost variant.
import { Bubble, BubbleContent } from "@codefast/ui/bubble";
const ghostText = `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.`;
function Paragraphs({ text }: { text: string }) {
return (
<>
{text.split(/\n{2,}/).map((paragraph) => (
<p key={paragraph} className="whitespace-pre-wrap">
{paragraph}
</p>
))}
</>
);
}
export function BubbleMarkdown() {
return (
<div className="flex w-full max-w-sm flex-col gap-8">
<Bubble align="end" variant="muted">
<BubbleContent>
<Paragraphs text="Hello! Are you actually thinking?" />
</BubbleContent>
</Bubble>
<Bubble variant="ghost">
<BubbleContent>
<Paragraphs text={ghostText} />
</BubbleContent>
</Bubble>
</div>
);
}
Popover
Anchor a Popover in the reaction slot to surface details like an error trace on demand.
import { Bubble, BubbleContent, BubbleReactions } from "@codefast/ui/bubble";
import { Button } from "@codefast/ui/button";
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@codefast/ui/popover";
import { InfoIcon } from "lucide-react";
export function BubblePopover() {
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<Bubble align="end">
<BubbleContent>Run the build script.</BubbleContent>
</Bubble>
<Bubble variant="destructive">
<BubbleContent>Failed to run the command.</BubbleContent>
<BubbleReactions>
<Popover>
<PopoverTrigger asChild>
<Button
aria-label="Show error details"
className="aria-expanded:text-destructive"
size="icon-xs"
variant="ghost"
>
<InfoIcon />
</Button>
</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>
);
}
Tooltip
Put a read receipt in the reaction slot and reveal its timestamp with a Tooltip.
import { Bubble, BubbleContent, BubbleReactions } from "@codefast/ui/bubble";
import { Button } from "@codefast/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@codefast/ui/tooltip";
import { CheckIcon } from "lucide-react";
export function BubbleTooltip() {
return (
<div className="flex w-full max-w-sm flex-col gap-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 className="p-0">
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon-xs" variant="ghost">
<CheckIcon />
</Button>
</TooltipTrigger>
<TooltipContent>Read on Jan 5, 2026 at 4:32 PM</TooltipContent>
</Tooltip>
</BubbleReactions>
</Bubble>
</div>
);
}
Usage
The minimal import and composition — see Examples below for styled, real-world variants.
import { Bubble, BubbleContent } from "@codefast/ui/bubble";
export function BubbleUsage() {
return (
<Bubble>
<BubbleContent>Hey, how's it going?</BubbleContent>
</Bubble>
);
}
Anatomy
How the parts nest — every slot the component exposes, in composition order.
Features
- Seven color variants that always paint the nested BubbleContent, independent of align.
- BubbleContent's asChild turns the whole bubble into a real button or link, e.g. a quick-reply chip.
- BubbleReactions overlaps a pill on a configurable corner (side/align) of the bubble.
API reference
Props for each part of the component. All native element props are also forwarded.
Bubble
Bubble wrapper. Sets the color variant and the side it sits on.
variant"default" | "secondary" | "muted" | "tinted" | "outline" | "ghost" | "destructive"Color treatment applied to the nested BubbleContent.
Default
"default"align"start" | "end"Which side the bubble aligns to. Also mirrors inside a Message with data-align.
Default
"start"
BubbleContent
The colored surface holding the message body.
asChildbooleanRender as the child element (e.g. a button or link) for an actionable bubble.
Default
false
BubbleReactions
Reaction pill overlapping a bubble corner.
side"top" | "bottom"Vertical edge to overlap.
Default
"bottom"align"start" | "end"Horizontal edge to anchor to.
Default
"end"
BubbleGroup
Vertical stack of consecutive bubbles from one author.
childrenReactNodeOne or more Bubble elements.
Accessibility
Built to be keyboard-navigable and screen-reader friendly out of the box.
- Bubble is presentational — convey author and timestamp with real text (see Message), not color alone.
- For an actionable bubble, use asChild with a real button or anchor so it stays keyboard-focusable.
- Logical spacing and alignment mirror automatically under a DirectionProvider (RTL).
Guidelines
Conventions that keep usage consistent across an app.
Do
- Pair with Message to attach an avatar, header, and footer.
- Reserve the destructive variant for failed or removed messages.
Don’t
- Don’t rely on variant color alone to distinguish authors — align and label them.
- Don’t nest interactive controls inside a non-asChild BubbleContent.
Explore further
Ready to integrate?
Follow the Getting Started guide to install @codefast/ui, or browse the full component gallery.