Bubble
A chat bubble in seven treatments, aligned to either side, with reactions.
Vibra's default bubble reads --brand and hovers by mixing ink into the fill rather than fading it to 80%; destructive is the danger tone on its opaque tint, and its hover is a ring rather than a deeper fill, which would take the pair under 4.5:1. The other five treatments are shadcn's. Reactions hang off the inline end or start rather than the right or left, and a button bubble's words align to the start, so a right-to-left thread mirrors whole.
Install
npx shadcn@latest add @vibra/bubbleNeeds the @vibra registry in your components.json — set it up once.
Examples
import { Bubble, BubbleContent, BubbleGroup } from "@/components/ui/bubble"
export default function BubbleDemo() {
return (
<BubbleGroup role="log" aria-label="Conversation with Hiro Tanaka" className="w-full max-w-sm">
<Bubble variant="muted">
<BubbleContent>Can you pull Q3 churn for Harbour Logistics before the renewal call?</BubbleContent>
</Bubble>
<Bubble align="end">
<BubbleContent>2.1% a month, down from 3.4% in Q2. The export is in the shared folder.</BubbleContent>
</Bubble>
<Bubble variant="muted">
<BubbleContent>Perfect — that goes on the first slide.</BubbleContent>
</Bubble>
</BubbleGroup>
)
}Seven treatments
Each variant named with the side of a conversation it is for. Default is the one accent in a thread.
import { Bubble, BubbleContent } from "@/components/ui/bubble"
const TREATMENTS = [
{ variant: "default", use: "you", text: "Sent to Maren." },
{ variant: "secondary", use: "them", text: "Pulling it now." },
{ variant: "muted", use: "them, quieter", text: "Send the deck?" },
{ variant: "tinted", use: "mentions you", text: "@Saoirse, ok?" },
{ variant: "outline", use: "a card", text: "Release notes" },
{ variant: "ghost", use: "the assistant", text: "Churn was 2.1%." },
{ variant: "destructive", use: "not sent", text: "Couldn’t send." },
] as const
// The seven treatments, each named with the side of a conversation it is for.
// Default is the one accent in a thread; everything else stays on the planes.
export default function BubbleVariants() {
return (
<ul className="grid w-full max-w-2xl grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-4">
{TREATMENTS.map((treatment) => (
<li key={treatment.variant} className="flex min-w-0 flex-col items-start gap-1">
<span className="text-xs text-muted-foreground">
<span className="font-mono text-foreground">{treatment.variant}</span> · {treatment.use}
</span>
<Bubble variant={treatment.variant} className="max-w-full">
<BubbleContent>{treatment.text}</BubbleContent>
</Bubble>
</li>
))}
</ul>
)
}A thread with faces
Runs of messages with the name over the first and the face by the last, a read receipt, and a message sent again.
"use client"
import * as React from "react"
import { getInitials } from "@/lib/format"
import { avatarFor } from "@/lib/avatars"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
import { Message, MessageAvatar, MessageContent, MessageFooter, MessageHeader } from "@/components/ui/message"
const HIRO = "Hiro Tanaka"
// Runs of messages, the way a thread groups them: the sender's name over the
// first of a run and their face beside the last, a read receipt under yours,
// and a message that didn't go says so in words with a way to send it again.
// Once it goes, the focus moves to the message itself rather than the page.
export default function BubbleThread() {
const [sent, setSent] = React.useState(false)
const failed = React.useRef<HTMLDivElement>(null)
function sendAgain() {
setSent(true)
requestAnimationFrame(() => failed.current?.focus())
}
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<Message>
<MessageAvatar className="min-w-7">
<Avatar size="sm" aria-hidden="true">
<AvatarImage src={avatarFor(HIRO)} alt="" />
<AvatarFallback>{getInitials(HIRO)}</AvatarFallback>
</Avatar>
</MessageAvatar>
<MessageContent className="gap-1">
<MessageHeader>{HIRO}</MessageHeader>
<Bubble variant="muted">
<BubbleContent>Is the Harbour renewal ready?</BubbleContent>
</Bubble>
<Bubble variant="muted">
<BubbleContent>They sign at 3 today.</BubbleContent>
</Bubble>
</MessageContent>
</Message>
<Message align="end">
<MessageContent className="gap-1">
<Bubble align="end">
<BubbleContent>Sent it an hour ago.</BubbleContent>
</Bubble>
<MessageFooter>Read 14:02</MessageFooter>
<Bubble align="end" variant={sent ? "default" : "destructive"}>
<BubbleContent ref={failed} tabIndex={-1} className="focus-ring">
Deck’s in the shared drive too.
</BubbleContent>
</Bubble>
<MessageFooter className="gap-1.5" role="status" aria-live="polite">
{sent ? (
"Delivered"
) : (
<>
Not delivered
<Button variant="link" size="xs" className="h-auto px-0 text-xs underline" onClick={sendAgain}>
Send again
</Button>
</>
)}
</MessageFooter>
</MessageContent>
</Message>
</div>
)
}Reactions
Toggle buttons hanging off the inline-end corner, yours pressed, each name carrying its count.
"use client"
import * as React from "react"
import { EyeIcon, PartyPopperIcon, ThumbsUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Bubble, BubbleContent, BubbleGroup, BubbleReactions } from "@/components/ui/bubble"
const REACTIONS = [
{ id: "up", label: "thumbs up", icon: ThumbsUpIcon, others: 3 },
{ id: "party", label: "celebration", icon: PartyPopperIcon, others: 1 },
]
function Reaction({ reaction }: { reaction: (typeof REACTIONS)[number] }) {
const [mine, setMine] = React.useState(reaction.id === "up")
const count = reaction.others + (mine ? 1 : 0)
return (
<button
type="button"
aria-pressed={mine}
aria-label={`React with ${reaction.label}, ${count} ${count === 1 ? "person" : "people"}`}
onClick={() => setMine(!mine)}
className={cn(
"flex h-6 items-center gap-1 rounded-full px-2 text-xs tabular-nums transition-colors duration-(--duration-fast) focus-ring",
mine ? "bg-brand-muted font-medium text-foreground" : "hover:bg-foreground/5"
)}
>
<reaction.icon aria-hidden="true" className="size-3.5" />
{count}
</button>
)
}
// Reactions hang off the bubble's inline-end corner as toggle buttons: yours
// is pressed and on the accent's muted fill, and each name carries the count.
// The Arabic message below keeps them at its own end, on the left.
export default function BubbleReactionsExample() {
return (
<BubbleGroup className="w-full max-w-sm gap-8 pb-3">
<Bubble variant="muted">
<BubbleContent>Harbour Logistics renewed for two more years.</BubbleContent>
<BubbleReactions>
{REACTIONS.map((reaction) => (
<Reaction key={reaction.id} reaction={reaction} />
))}
</BubbleReactions>
</Bubble>
<div dir="rtl" lang="ar" className="flex flex-col">
<Bubble variant="muted">
<BubbleContent>جدّدت هاربر للخدمات اللوجستية عقدها لسنتين.</BubbleContent>
<BubbleReactions>
<span className="flex h-6 items-center gap-1 px-2 text-xs tabular-nums">
<EyeIcon aria-hidden="true" className="size-3.5" />
<span aria-hidden="true">2</span>
<span className="sr-only">شاهدها شخصان</span>
</span>
</BubbleReactions>
</Bubble>
</div>
</BubbleGroup>
)
}Links and buttons
A bubble rendered as a link to the release notes, and one as a button that copies an invite.
"use client"
import { ArrowUpRightIcon, LinkIcon } from "lucide-react"
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
import { Bubble, BubbleContent, BubbleGroup } from "@/components/ui/bubble"
const INVITE = "https://northwind.example/join/7Qm2Kx"
// A bubble can be the thing it talks about: rendered as a link, it goes to the
// release notes; rendered as a button, it copies the invite. Both keep the
// bubble's shape and take the kit's focus ring and a hover of their own.
export default function BubbleLinks() {
const { copied, copy } = useCopyToClipboard(2000)
return (
<BubbleGroup className="w-full max-w-sm">
<Bubble variant="muted">
<BubbleContent>Release 2.14 is out. The notes:</BubbleContent>
</Bubble>
<Bubble variant="outline">
<BubbleContent render={<a href="/changelog#2-14" />} className="flex flex-col gap-0.5">
<span className="flex items-center gap-1 font-medium">
Release 2.14 · Scheduled exports
<ArrowUpRightIcon aria-hidden="true" className="size-3.5" />
</span>
<span className="text-xs text-muted-foreground">northwind.example/changelog</span>
</BubbleContent>
</Bubble>
<Bubble align="end">
<BubbleContent render={<button type="button" onClick={() => void copy(INVITE)} />} className="flex items-center gap-1.5">
<LinkIcon aria-hidden="true" className="size-3.5" />
{copied ? "Copied the invite link" : "Copy the invite link for Maren"}
</BubbleContent>
</Bubble>
<p role="status" aria-live="polite" className="sr-only">
{copied ? "Invite link copied." : ""}
</p>
</BubbleGroup>
)
}Long message
Folds to three lines behind a toggle that reports what it controls and keeps the focus.
"use client"
import * as React from "react"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
const SUMMARY =
"Short version: the September close is on track. Revenue came in at $48,290, up 6.2% on August, and churn fell to 2.1% a month. Two things need a decision before Friday: whether Harbour Logistics gets the multi-year discount it asked for, and whether the Q3 board deck goes out with the unaudited ledger or waits for Nadia's review on Monday."
// A long message folds to three lines. The toggle says which way it will go,
// reports aria-expanded against the text it controls, and keeps the focus.
export default function BubbleLong() {
const [open, setOpen] = React.useState(false)
const id = React.useId()
return (
<Bubble variant="muted" className="max-w-sm">
<BubbleContent className="flex flex-col items-start gap-1">
<p id={id} className={open ? undefined : "line-clamp-3"}>
{SUMMARY}
</p>
<Button
variant="link"
size="xs"
aria-expanded={open}
aria-controls={id}
onClick={() => setOpen(!open)}
className="h-auto px-0 text-xs underline"
>
{open ? "Show less" : "Show more"}
</Button>
</BubbleContent>
</Bubble>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "default" | "secondary" | "muted" | "tinted" | "outline" | "ghost" | "destructive" | "default" | default is the accent fill for your own messages; muted or outline suit the other side. |
| align | "start" | "end" | "start" | Which side of the thread the bubble sits on. |
| BubbleReactions.side / align | "top" | "bottom" / "start" | "end" | "bottom" / "end" | Where the reactions pill hangs off the bubble. |
Dependencies
Source
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 "@/lib/utils"
function BubbleGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="bubble-group"
className={cn("flex min-w-0 flex-col gap-2", className)}
{...props}
/>
)
}
const bubbleVariants = cva(
"group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full",
{
variants: {
variant: {
default:
"*:data-[slot=bubble-content]:bg-brand *:data-[slot=bubble-content]:text-brand-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--brand),var(--foreground)_10%)]",
secondary:
"*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]",
muted:
"*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]",
tinted:
"*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]",
outline:
"*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30",
ghost:
"border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50",
// The one variant whose hover is a ring rather than a deeper fill: the
// fill is a tint of the text colour, so deepening it takes the pair
// under 4.5:1 (3.95 at 20%, 3.28 at 30% in the worst palette).
destructive:
"*:data-[slot=bubble-content]:bg-danger-muted *:data-[slot=bubble-content]:text-danger [&>[data-slot=bubble-content]:is(button,a):hover]:ring-1 [&>[data-slot=bubble-content]:is(button,a):hover]:ring-danger",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Bubble({
variant = "default",
align = "start",
className,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof bubbleVariants> & {
align?: "start" | "end"
}) {
return (
<div
data-slot="bubble"
data-variant={variant}
data-align={align}
className={cn(bubbleVariants({ variant }), className)}
{...props}
/>
)
}
function BubbleContent({
className,
render,
...props
}: useRender.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"w-fit max-w-full min-w-0 overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-start [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:focus-outline",
className
),
},
props
),
render,
state: {
slot: "bubble-content",
},
})
}
const bubbleReactionsVariants = cva(
"absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0",
{
variants: {
side: {
top: "top-0 -translate-y-3/4",
bottom: "bottom-0 translate-y-3/4",
},
// Logical, so "end" is the end of the line in either direction.
align: {
start: "start-3",
end: "end-3",
},
},
defaultVariants: {
side: "bottom",
align: "end",
},
}
)
function BubbleReactions({
side = "bottom",
align = "end",
className,
...props
}: React.ComponentProps<"div"> & {
align?: "start" | "end"
side?: "top" | "bottom"
}) {
return (
<div
data-slot="bubble-reactions"
data-align={align}
data-side={side}
className={cn(bubbleReactionsVariants({ side, align }), className)}
{...props}
/>
)
}
export { BubbleGroup, Bubble, BubbleContent, BubbleReactions }