Chat message bubbles with variants, grouped corners, reactions and room for interactive content.
import {
Bubble,
BubbleContent,
BubbleGroup,
BubbleReactions,
} from "@/components/ui/bubble"
export function BubbleDemo() {
return (
<div className="flex w-full max-w-md flex-col gap-3">
<Bubble variant="secondary">
<BubbleContent>
I checked the registry output and removed the stale route.
</BubbleContent>
<BubbleReactions role="img" aria-label="Reactions: thumbs up">
<span>👍</span>
</BubbleReactions>
</Bubble>
<BubbleGroup>
<Bubble align="end">
<BubbleContent>Nice, thanks!</BubbleContent>
</Bubble>
<Bubble align="end">
<BubbleContent>Did the preview deploy pick it up?</BubbleContent>
</Bubble>
</BubbleGroup>
<Bubble variant="ghost">
<BubbleContent>
Yes. The preview at build 1482 serves the new route, and the old one
now returns a 404 as expected.
</BubbleContent>
</Bubble>
</div>
)
}pnpm dlx shadcn@latest add https://hextaui.com/r/bubble.jsonAdds the component, the HextaUI theme tokens and any HextaUI components it depends on.
- 1
Add the theme tokens to your global CSS, if you haven’t yet.
- 2
Install the dependencies.
pnpm add @base-ui/react class-variance-authority cn - 3
Copy and paste the following code into your project.
components/ui/bubble.tsx "use client" import * as React from "react" import { mergeProps } from "@base-ui/react/merge-props" import { useRender } from "@base-ui/react/use-render" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" type BubbleShape = "uniform" | "joined" | "tail" const BubbleShapeContext = React.createContext<BubbleShape | undefined>( undefined ) const BubbleAlignContext = React.createContext<"start" | "end" | undefined>( undefined ) function dataAttributes(slot: string, extra?: Record<string, string>) { return { "data-slot": slot, ...extra } as Record<string, string> } type BubbleGroupProps = useRender.ComponentProps<"div"> & { shape?: BubbleShape } function BubbleGroup({ className, shape, render, ...props }: BubbleGroupProps) { const element = useRender({ defaultTagName: "div", render, props: mergeProps<"div">( { className: cn("flex min-w-0 flex-col gap-1", className) }, props, dataAttributes("bubble-group", shape ? { "data-shape": shape } : {}) ), }) return ( <BubbleShapeContext.Provider value={shape}> {element} </BubbleShapeContext.Provider> ) } const bubbleVariants = cva( "group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 [--bubble-bg:transparent] [--bubble-border:transparent] [--bubble-fg:currentColor] [--bubble-radius-bottom:var(--bubble-radius)] [--bubble-radius-joined:var(--radius-sm)] [--bubble-radius-top:var(--bubble-radius)] [--bubble-radius:var(--radius-xl)] [--bubble-tail:none] group-data-[align=end]/message:ms-auto in-data-[slot=bubble-content]:[--bubble-radius:max(calc(var(--radius-sm)*0.5),calc(var(--bubble-content-radius)-var(--spacing)*2))]", { variants: { variant: { default: "[--bubble-bg:var(--color-primary)] [--bubble-fg:var(--color-primary-foreground)]", secondary: "[--bubble-bg:var(--color-secondary)] [--bubble-fg:var(--color-secondary-foreground)]", muted: "[--bubble-bg:var(--color-muted)] [--bubble-fg:var(--color-foreground)]", tinted: "[--bubble-bg:oklch(from_var(--color-primary)_0.94_calc(c*0.4)_h)] [--bubble-fg:var(--color-foreground)] dark:[--bubble-bg:oklch(from_var(--color-primary)_0.3_calc(c*0.4)_h)]", outline: "[--bubble-bg:var(--color-background)] [--bubble-border:var(--color-border)] [--bubble-fg:var(--color-foreground)] dark:[--bubble-border:var(--color-input)]", ghost: "max-w-full [--bubble-radius:var(--radius-md)] *:data-[slot=bubble-content]:p-0", destructive: "[--bubble-accent:var(--color-destructive)] [--bubble-bg:color-mix(in_oklab,var(--color-destructive)_10%,var(--color-background))] [--bubble-fg:var(--color-foreground)] dark:[--bubble-bg:color-mix(in_oklab,var(--color-destructive)_18%,var(--color-background))]", }, align: { start: "[--bubble-tail-flip:-1] *:data-[slot=bubble-content]:rounded-ss-(--bubble-radius-top) *:data-[slot=bubble-content]:rounded-es-(--bubble-radius-bottom) rtl:[--bubble-tail-flip:1]", end: "ms-auto [--bubble-tail-flip:1] *:data-[slot=bubble-content]:self-end *:data-[slot=bubble-content]:rounded-se-(--bubble-radius-top) *:data-[slot=bubble-content]:rounded-ee-(--bubble-radius-bottom) rtl:[--bubble-tail-flip:-1]", }, shape: { uniform: "", joined: "", tail: "[--bubble-tail-cut:calc(var(--spacing)*2)] [--bubble-tail-height:calc(var(--spacing)*5)] [--bubble-tail-overlap:calc(var(--spacing)*2.5)] [--bubble-tail-width:calc(var(--spacing)*1.5)] [--bubble-tail:block] *:data-[slot=bubble-content]:relative *:data-[slot=bubble-content]:overflow-visible *:data-[slot=bubble-content]:after:pointer-events-none *:data-[slot=bubble-content]:after:absolute *:data-[slot=bubble-content]:after:bottom-0 *:data-[slot=bubble-content]:after:[display:var(--bubble-tail)] *:data-[slot=bubble-content]:after:h-(--bubble-tail-height) *:data-[slot=bubble-content]:after:w-[calc(var(--bubble-tail-width)+var(--bubble-tail-overlap))] *:data-[slot=bubble-content]:after:[scale:var(--bubble-tail-flip)_1] *:data-[slot=bubble-content]:after:rounded-bl-[calc(var(--spacing)*3.25)_calc(var(--spacing)*2.75)] *:data-[slot=bubble-content]:after:bg-(--bubble-bg) *:data-[slot=bubble-content]:after:[mask-image:linear-gradient(to_right,black_var(--bubble-tail-overlap),transparent_var(--bubble-tail-overlap)),radial-gradient(circle_var(--bubble-tail-cut)_at_100%_0,transparent_var(--bubble-tail-cut),black_calc(var(--bubble-tail-cut)+0.5px))] *:data-[slot=bubble-content]:after:[mask-size:100%_100%,var(--bubble-tail-cut)_var(--bubble-tail-cut)] *:data-[slot=bubble-content]:after:[mask-position:0_0,var(--bubble-tail-overlap)_100%] *:data-[slot=bubble-content]:after:[mask-repeat:no-repeat]", }, }, compoundVariants: [ { shape: "joined", align: "start", className: "[[data-slot=bubble-group]>&:has(+[data-slot=bubble][data-align=start])]:[--bubble-radius-bottom:var(--bubble-radius-joined)] [[data-slot=bubble-group]>[data-slot=bubble][data-align=start]+&]:[--bubble-radius-top:var(--bubble-radius-joined)]", }, { shape: "joined", align: "end", className: "[[data-slot=bubble-group]>&:has(+[data-slot=bubble][data-align=end])]:[--bubble-radius-bottom:var(--bubble-radius-joined)] [[data-slot=bubble-group]>[data-slot=bubble][data-align=end]+&]:[--bubble-radius-top:var(--bubble-radius-joined)]", }, { shape: "tail", align: "start", className: "ps-(--bubble-tail-width) *:data-[slot=bubble-content]:after:start-[calc(var(--bubble-tail-width)*-1)] [[data-slot=bubble-group]>&:has(+[data-slot=bubble][data-align=start])]:[--bubble-tail:none]", }, { shape: "tail", align: "end", className: "pe-(--bubble-tail-width) *:data-[slot=bubble-content]:after:end-[calc(var(--bubble-tail-width)*-1)] [[data-slot=bubble-group]>&:has(+[data-slot=bubble][data-align=end])]:[--bubble-tail:none]", }, { shape: "tail", variant: ["outline", "ghost"], className: "[--bubble-tail:none]", }, ], defaultVariants: { variant: "default", align: "start", shape: "joined", }, } ) type BubbleVariant = NonNullable<VariantProps<typeof bubbleVariants>["variant"]> type BubbleAlign = NonNullable<VariantProps<typeof bubbleVariants>["align"]> type BubbleProps = useRender.ComponentProps<"div"> & { variant?: BubbleVariant align?: BubbleAlign shape?: BubbleShape } function Bubble({ className, variant = "default", align: alignProp, shape: shapeProp, render, ...props }: BubbleProps) { const groupShape = React.useContext(BubbleShapeContext) const inheritedAlign = React.useContext(BubbleAlignContext) const align = alignProp ?? inheritedAlign ?? "start" const shape = shapeProp ?? groupShape ?? "joined" const element = useRender({ defaultTagName: "div", render, props: mergeProps<"div">( { className: cn(bubbleVariants({ variant, align, shape }), className) }, props, dataAttributes("bubble", { "data-variant": variant, "data-align": align, "data-shape": shape, }) ), }) return ( <BubbleShapeContext.Provider value={undefined}> {element} </BubbleShapeContext.Provider> ) } function BubbleContent({ className, render, ...props }: useRender.ComponentProps<"div">) { return useRender({ defaultTagName: "div", render, props: mergeProps<"div">( { className: cn( "w-fit max-w-full min-w-0 overflow-hidden rounded-(--bubble-radius) bg-(--bubble-bg) px-3 py-2 text-sm leading-relaxed wrap-anywhere text-(--bubble-fg) inset-ring-(length:--hairline) inset-ring-(--bubble-border) outline-none [--bubble-content-radius:var(--bubble-radius)] focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden forced-colors:border [&:is(button,a)]:text-start [&>svg]:inline-block [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:align-[-0.1875rem] [&>svg]:text-(--bubble-accent,currentColor)", className ), }, props, dataAttributes("bubble-content") ), }) } const bubbleReactionsVariants = cva( "relative z-10 flex w-max max-w-[calc(100%-var(--spacing)*3)] flex-wrap items-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm leading-5 ring-3 ring-background select-none has-[>button]:p-0 *:data-[slot=button]:rounded-full", { variants: { side: { top: "order-first -mb-2.5", bottom: "-mt-2.5", }, align: { start: "ms-3 self-start", end: "me-3 self-end", }, }, defaultVariants: { side: "bottom", align: "end", }, } ) type BubbleReactionsProps = useRender.ComponentProps<"div"> & { side?: NonNullable<VariantProps<typeof bubbleReactionsVariants>["side"]> align?: NonNullable<VariantProps<typeof bubbleReactionsVariants>["align"]> } function BubbleReactions({ className, side = "bottom", align = "end", render, ...props }: BubbleReactionsProps) { return useRender({ defaultTagName: "div", render, props: mergeProps<"div">( { className: cn(bubbleReactionsVariants({ side, align }), className), }, props, dataAttributes("bubble-reactions", { "data-side": side, "data-align": align, }) ), }) } export { Bubble, BubbleAlignContext, BubbleContent, BubbleGroup, BubbleReactions, bubbleReactionsVariants, bubbleVariants, } export type { BubbleAlign, BubbleGroupProps, BubbleProps, BubbleReactionsProps, BubbleShape, BubbleVariant, } - 4
Update the import paths to match your project setup.
import {
Bubble,
BubbleContent,
BubbleGroup,
BubbleReactions,
} from "@/components/ui/bubble"<BubbleGroup>
<Bubble variant="secondary">
<BubbleContent>Did the deploy land?</BubbleContent>
</Bubble>
<Bubble align="end">
<BubbleContent>It did.</BubbleContent>
</Bubble>
</BubbleGroup>BubbleGroup
└── Bubble
├── BubbleContent
└── BubbleReactionsVariants#
Seven treatments, from a strong primary bubble to unframed ghost content for assistant replies. destructive tints the surface and colors icons inside it.
import { IconAlertCircle } from "@tabler/icons-react"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
export function BubbleVariants() {
return (
<div className="flex w-full max-w-md flex-col gap-3">
<Bubble>
<BubbleContent>Pushed the fix, can you take a look?</BubbleContent>
</Bubble>
<Bubble variant="secondary">
<BubbleContent>Looks good. Merging after CI passes.</BubbleContent>
</Bubble>
<Bubble variant="muted">
<BubbleContent>Jamie joined the conversation</BubbleContent>
</Bubble>
<Bubble variant="tinted">
<BubbleContent>Pinned: release notes are in the doc.</BubbleContent>
</Bubble>
<Bubble variant="outline">
<BubbleContent>Here is the summary you asked for.</BubbleContent>
</Bubble>
<Bubble variant="ghost">
<BubbleContent>
Ghost bubbles drop the frame so assistant replies can span the full
row and read like a document.
</BubbleContent>
</Bubble>
<Bubble variant="destructive">
<BubbleContent>
<IconAlertCircle /> Message failed to send. Tap to retry.
</BubbleContent>
</Bubble>
</div>
)
}Alignment#
align="end" pushes a bubble to the end of the row for the current user’s messages. It follows the reading direction.
import { Bubble, BubbleContent } from "@/components/ui/bubble"
export function BubbleAlignment() {
return (
<div className="flex w-full max-w-md flex-col gap-3">
<Bubble variant="secondary" align="start">
<BubbleContent>Aligned to the start.</BubbleContent>
</Bubble>
<Bubble align="end">
<BubbleContent>Aligned to the end.</BubbleContent>
</Bubble>
</div>
)
}Group#
Wrap consecutive messages from one sender in <BubbleGroup />. Corners on the sender side tighten so the run reads as one block, and a lone bubble stays fully round.
import { Bubble, BubbleContent, BubbleGroup } from "@/components/ui/bubble"
export function BubbleGroupDemo() {
return (
<div className="flex w-full max-w-md flex-col gap-3">
<BubbleGroup>
<Bubble variant="secondary">
<BubbleContent>Hey!</BubbleContent>
</Bubble>
<Bubble variant="secondary">
<BubbleContent>Did the deploy land?</BubbleContent>
</Bubble>
<Bubble variant="secondary">
<BubbleContent>
Asking because the dashboard still shows the old build number and I
want to confirm before I post the announcement.
</BubbleContent>
</Bubble>
</BubbleGroup>
<BubbleGroup>
<Bubble align="end">
<BubbleContent>It did.</BubbleContent>
</Bubble>
<Bubble align="end">
<BubbleContent>Hard refresh 🙏</BubbleContent>
</Bubble>
</BubbleGroup>
<BubbleGroup>
<Bubble variant="outline">
<BubbleContent>A single bubble in a group stays round.</BubbleContent>
</Bubble>
</BubbleGroup>
</div>
)
}Shapes#
Set shape on a group to style all of its bubbles, or on a single bubble. joined is the default.
uniform Every bubble fully round.
joined Grouped corners tighten on the sender side.
tail The last bubble of a group gets a tail.
import { Bubble, BubbleContent, BubbleGroup } from "@/components/ui/bubble"
const shapes = [
{ shape: "uniform", description: "Every bubble fully round." },
{
shape: "joined",
description: "Grouped corners tighten on the sender side.",
},
{ shape: "tail", description: "The last bubble of a group gets a tail." },
] as const
export function BubbleShapes() {
return (
<div className="flex w-full max-w-md flex-col gap-10">
{shapes.map(({ shape, description }) => (
<div key={shape} className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground">
<span className="font-medium text-foreground">{shape}</span>{" "}
{description}
</p>
<BubbleGroup shape={shape}>
<Bubble variant="secondary">
<BubbleContent>Hey!</BubbleContent>
</Bubble>
<Bubble variant="secondary">
<BubbleContent>Did the deploy land?</BubbleContent>
</Bubble>
</BubbleGroup>
<BubbleGroup shape={shape}>
<Bubble align="end">
<BubbleContent>It did.</BubbleContent>
</Bubble>
<Bubble align="end">
<BubbleContent>
Hard refresh and you should see build 1482 on the dashboard.
</BubbleContent>
</Bubble>
</BubbleGroup>
<Bubble shape={shape} variant="secondary">
<BubbleContent>Perfect 🙏</BubbleContent>
</Bubble>
</div>
))}
</div>
)
}Tail#
shape="tail" draws a tail on the last bubble of a run, matching the bubble color. Outline and ghost bubbles skip it.
import {
Bubble,
BubbleContent,
BubbleGroup,
BubbleReactions,
} from "@/components/ui/bubble"
export function BubbleTail() {
return (
<div className="flex w-full max-w-md flex-col gap-3">
<Bubble shape="tail">
<BubbleContent>Pushed the fix, can you take a look?</BubbleContent>
</Bubble>
<Bubble shape="tail" variant="secondary" align="end">
<BubbleContent>Looks good. Merging after CI passes.</BubbleContent>
</Bubble>
<Bubble shape="tail" variant="muted">
<BubbleContent>Jamie joined the conversation</BubbleContent>
</Bubble>
<Bubble shape="tail" variant="tinted" align="end">
<BubbleContent>Pinned: release notes are in the doc.</BubbleContent>
</Bubble>
<Bubble shape="tail" variant="outline">
<BubbleContent>Outline bubbles never get a tail.</BubbleContent>
</Bubble>
<Bubble shape="tail" variant="destructive" align="end">
<BubbleContent>Message failed to send. Tap to retry.</BubbleContent>
</Bubble>
<Bubble shape="tail" align="end">
<BubbleContent>Reactions sit on the top corner.</BubbleContent>
<BubbleReactions side="top" align="start" role="img" aria-label="Heart">
<span>❤️</span>
</BubbleReactions>
</Bubble>
<Bubble shape="tail" variant="secondary">
<BubbleContent>ok</BubbleContent>
</Bubble>
<BubbleGroup shape="tail">
<Bubble variant="secondary">
<BubbleContent>Only the last bubble in a group</BubbleContent>
</Bubble>
<Bubble variant="secondary">
<BubbleContent>gets the tail.</BubbleContent>
</Bubble>
</BubbleGroup>
</div>
)
}Reactions#
<BubbleReactions /> overlaps the bubble edge and reserves its own space, so the next row needs no extra gap. Give a static row role="img" and a label, or use buttons for interactive reactions.
"use client"
import * as React from "react"
import {
IconFlame,
IconHeart,
IconMoodSmile,
IconThumbUp,
} from "@tabler/icons-react"
import { Bubble, BubbleContent, BubbleReactions } from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
function ReactionToggle({
label,
icon,
initial = false,
}: {
label: string
icon: React.ReactNode
initial?: boolean
}) {
const [pressed, setPressed] = React.useState(initial)
return (
<Button
aria-label={label}
aria-pressed={pressed}
variant={pressed ? "default" : "secondary"}
size="icon-xs"
onClick={() => setPressed(!pressed)}
>
{icon}
</Button>
)
}
export function BubbleReactionsDemo() {
return (
<div className="flex w-full max-w-md flex-col gap-6">
<Bubble variant="secondary">
<BubbleContent>Shipped the new onboarding flow.</BubbleContent>
<BubbleReactions
role="img"
aria-label="Reactions: thumbs up, fire, and 8 more"
>
<span>👍</span>
<span>🔥</span>
<span>+8</span>
</BubbleReactions>
</Bubble>
<Bubble align="end">
<BubbleContent>Reactions can anchor to the top.</BubbleContent>
<BubbleReactions side="top" align="start" role="img" aria-label="Heart">
<span>❤️</span>
</BubbleReactions>
</Bubble>
<Bubble variant="secondary">
<BubbleContent>ok</BubbleContent>
<BubbleReactions
role="img"
aria-label="Reactions: party, eyes, rocket, check"
>
<span>🎉</span>
<span>👀</span>
<span>🚀</span>
<span>✅</span>
</BubbleReactions>
</Bubble>
<Bubble variant="outline">
<BubbleContent>Interactive reactions use buttons.</BubbleContent>
<BubbleReactions align="start">
<ReactionToggle label="Thumbs up" icon={<IconThumbUp />} initial />
<ReactionToggle label="Heart" icon={<IconHeart />} />
<ReactionToggle label="Fire" icon={<IconFlame />} />
<ReactionToggle label="Add reaction" icon={<IconMoodSmile />} />
</BubbleReactions>
</Bubble>
</div>
)
}Show more#
Render a <Collapsible /> as the bubble content to fold long messages. The bubble grows smoothly and the hidden text stays searchable.
Here is the full incident timeline. The first alert fired at 09:12 when p95 latency crossed 800ms on the checkout service.
At 09:20 we rolled back the connection pool change. Latency recovered by 09:24 and error rates returned to baseline by 09:31. A follow-up will add a canary stage for pool configuration.
import { IconChevronDown } from "@tabler/icons-react"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
export function BubbleShowMore() {
return (
<div className="flex w-full max-w-md flex-col gap-3">
<Bubble variant="secondary">
<Collapsible render={<BubbleContent />}>
<p>
Here is the full incident timeline. The first alert fired at 09:12
when p95 latency crossed 800ms on the checkout service.
</p>
<CollapsibleContent>
<p className="pt-2">
At 09:20 we rolled back the connection pool change. Latency
recovered by 09:24 and error rates returned to baseline by 09:31.
A follow-up will add a canary stage for pool configuration.
</p>
</CollapsibleContent>
<CollapsibleTrigger
render={
<button
type="button"
className="mt-1 inline-flex cursor-pointer items-center gap-1 rounded-sm font-medium underline-offset-4 outline-none hover:underline focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:outline-hidden"
/>
}
>
<span className="group-data-panel-open/collapsible-trigger:hidden">
Show more
</span>
<span className="hidden group-data-panel-open/collapsible-trigger:inline">
Show less
</span>
<IconChevronDown className="size-4 transition-transform duration-200 ease-out-cubic group-data-panel-open/collapsible-trigger:rotate-180 motion-reduce:transition-none" />
</CollapsibleTrigger>
</Collapsible>
</Bubble>
</div>
)
}Tooltip#
Render a bubble as a tooltip trigger to show details like the read time. Add tabIndex so keyboard users can reach it.
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
export function BubbleTooltip() {
return (
<div className="flex w-full max-w-md flex-col gap-3">
<Tooltip>
<TooltipTrigger render={<Bubble align="end" tabIndex={0} />}>
<BubbleContent>Hover to see when this was read.</BubbleContent>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={8}>
Read 9:41 AM
</TooltipContent>
</Tooltip>
</div>
)
}Popover#
Render the content as a <button> to make the whole bubble a popover trigger, for example to explain a failed delivery.
import { IconAlertCircle } from "@tabler/icons-react"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover"
export function BubblePopover() {
return (
<div className="flex w-full max-w-md flex-col gap-3">
<Popover>
<Bubble variant="destructive" align="end">
<PopoverTrigger
render={<BubbleContent render={<button type="button" />} />}
>
<IconAlertCircle /> Not delivered. Tap for details.
</PopoverTrigger>
</Bubble>
<PopoverContent align="end" className="w-64">
<PopoverHeader>
<PopoverTitle>Delivery failed</PopoverTitle>
<PopoverDescription>
The recipient's server rejected the message (550: mailbox
unavailable).
</PopoverDescription>
</PopoverHeader>
</PopoverContent>
</Popover>
</div>
)
}Long content#
Unbroken strings, URLs, emoji and CJK text wrap inside the bubble instead of overflowing a narrow column.
import { Bubble, BubbleContent } from "@/components/ui/bubble"
export function BubbleLongContent() {
return (
<div className="flex w-72 max-w-full flex-col gap-3">
<Bubble variant="secondary">
<BubbleContent>
Supercalifragilisticexpialidocious_unbroken_string_that_never_ends_1234567890
</BubbleContent>
</Bubble>
<Bubble align="end">
<BubbleContent>
https://example.com/a/very/long/path/that/keeps/going/and/going?with=query&params=true
</BubbleContent>
</Bubble>
<Bubble variant="outline">
<BubbleContent>
👩👩👧👦👨🏽💻🏳️🌈 你好世界,这是一个很长的中文句子用于测试换行
</BubbleContent>
</Bubble>
<Bubble variant="muted" align="end">
<BubbleContent>k</BubbleContent>
</Bubble>
<Bubble variant="tinted">
<BubbleContent> </BubbleContent>
</Bubble>
</div>
)
}Nested reply#
A quoted bubble inside another keeps radii concentric with its parent.
Yes, Friday works.
import { Bubble, BubbleContent } from "@/components/ui/bubble"
export function BubbleNested() {
return (
<div className="flex w-full max-w-md flex-col gap-3">
<Bubble align="end">
<BubbleContent>
<Bubble variant="tinted">
<BubbleContent>Original: can we ship Friday?</BubbleContent>
</Bubble>
<p className="pt-2">Yes, Friday works.</p>
</BubbleContent>
</Bubble>
</div>
)
}Right to left#
Alignment, joined corners, tails and reactions all mirror in right-to-left layouts.
import {
Bubble,
BubbleContent,
BubbleGroup,
BubbleReactions,
} from "@/components/ui/bubble"
export function BubbleRtl() {
return (
<div dir="rtl" className="flex w-full max-w-md flex-col gap-3">
<BubbleGroup>
<Bubble variant="secondary">
<BubbleContent>مرحبا! هل وصل التحديث؟</BubbleContent>
</Bubble>
<Bubble variant="secondary">
<BubbleContent>أنا أنتظر التأكيد.</BubbleContent>
<BubbleReactions role="img" aria-label="إعجاب">
<span>👍</span>
<span dir="ltr">+3</span>
</BubbleReactions>
</Bubble>
</BubbleGroup>
<BubbleGroup shape="tail">
<Bubble align="end">
<BubbleContent>نعم، تم النشر.</BubbleContent>
</Bubble>
<Bubble align="end">
<BubbleContent render={<button type="button" />}>
أرني السجل
</BubbleContent>
</Bubble>
</BubbleGroup>
</div>
)
}- Bubbles are plain containers. Wrap a conversation in a list or a
role="log"region if new messages should be announced. - Emoji-only reaction rows need
role="img"and anaria-labelthat spells out the reactions. - Interactive content rendered as a button or link gets a focus ring that follows the bubble’s corners.
Every part renders a <div> by default and accepts its attributes, plus a render prop to swap the element.
| Prop | Type | Default |
|---|---|---|
variant | "default" | "secondary" | "muted" | "tinted" | "outline" | "ghost" | "destructive" | "default" |
align | "start" | "end" | "start" |
shapeInherited from BubbleGroup when not set. | "uniform" | "joined" | "tail" | "joined" |
render | ReactElement | (props, state) => ReactElement | <div> |
| Attribute | Description |
|---|---|
data-slot="bubble" | Target bubbles in CSS. |
data-variant | The current variant. |
data-align | The current alignment. |
data-shape | The resolved shape. |
--bubble-bg | The surface color, also used by the tail. |
--bubble-fg | The text color. |
--bubble-border | The ring color. |
--bubble-radius | The corner radius. |
--bubble-radius-joined | The tighter radius used between grouped bubbles. |
--bubble-accent | Icon color inside the content. Set by destructive. |
| Prop | Type | Default |
|---|---|---|
renderRender a <button> or <a> to make it interactive. | ReactElement | (props, state) => ReactElement | <div> |
| Attribute | Description |
|---|---|
data-slot="bubble-content" | Target the bubble surface in CSS. |
| Prop | Type | Default |
|---|---|---|
shapeApplies to every bubble inside the group. | "uniform" | "joined" | "tail" | – |
render | ReactElement | (props, state) => ReactElement | <div> |
| Attribute | Description |
|---|---|
data-slot="bubble-group" | Target groups in CSS. |
data-shape | The group shape, when set. |
| Prop | Type | Default |
|---|---|---|
side | "top" | "bottom" | "bottom" |
align | "start" | "end" | "end" |
render | ReactElement | (props, state) => ReactElement | <div> |
| Attribute | Description |
|---|---|
data-slot="bubble-reactions" | Target reaction rows in CSS. |
data-side | The current side. |
data-align | The current alignment. |