Bubble
A chat bubble with grouping and reactions for human conversations.
- Status
- Experimental
- Level
- Molecule
- Category
- Content
- Adoption
- Not used yet
import { Bubble } from "@oration/canon/components/bubble";packages/canon/src/components/bubble.tsximport { Bubble, BubbleContent, BubbleGroup } from "@oration/canon/components/bubble";import { CheckIcon } from "lucide-react";export function Hero() { return ( <div role="log" aria-label="Chat with Halcyon Supply" className="flex w-full max-w-md flex-col gap-4 rounded-xl bg-card p-4 shadow-border" > <BubbleGroup> <span className="px-1 text-xs text-muted-foreground"> Halcyon Supply </span> <Bubble variant="muted"> <BubbleContent> Hi, we still haven't received the remittance for the Sep 25 run. </BubbleContent> </Bubble> <Bubble variant="muted"> <BubbleContent>Invoice INV-20877, $6,410.00.</BubbleContent> </Bubble> <span className="px-1 text-2xs text-muted-foreground tabular-nums"> 9:41 AM </span> </BubbleGroup> <BubbleGroup className="items-end"> <span className="px-1 text-xs text-muted-foreground"> Jordan Lee </span> <Bubble variant="outline" align="end"> <BubbleContent> Thanks for flagging it. INV-20877 was paid by ACH on Sep 25 and the remittance went to ap@halcyonsupply.com. </BubbleContent> </Bubble> <Bubble variant="outline" align="end"> <BubbleContent>I've resent it just now.</BubbleContent> </Bubble> <span className="flex items-center gap-1 px-1 text-2xs text-muted-foreground tabular-nums"> 9:43 AM <CheckIcon aria-hidden="true" className="size-3" /> Delivered </span> </BubbleGroup> </div> );}Usage#
Bubble is one chat message's body: a rounded fill that sits left for the other party and right for your side, grouped with its neighbours and optionally carrying reactions. It's for human conversations on chat, SMS, WhatsApp and the web widget, usually inside a Message. It is experimental and the Contact Center thread still hand-rolls its bubbles. The mistake is the default variant: it fills the bubble with Quiet Indigo, which The Quiet Indigo Rule saves for the primary action, selection and focus, so pick muted and outline for conversations.
When to use
- For messages in a chat-style channel: web widget chat, SMS, WhatsApp, social DMs.
- For a run of consecutive messages from one sender, inside a
BubbleGroup. - For quick replies people tap in the widget, with
BubbleContentrendered as a button. - For a message that failed to send, in the
destructivetint with a retry.
When not to use
- For email, where each message needs sender, recipients and quoted text. Use Message
- For an AI assistant's turn in the Copilot or Knowledge base. Use AI message
- For internal notes between agents. Those are tint wells in the thread, not bubbles. Use Well
- For a call transcript. Use Live transcript
The Quiet Indigo Rule
muted for the other party and outline for your side; the indigo default variant stays out of the thread.The Label-Beside-Color Rule
Anatomy#
- Group.
BubbleGroup: a column of consecutive bubbles from one sender, 8px apart. - Bubble.
Bubble: the wrapper that sets the variant and alignment, up to 80% of the thread's width. - Content.
BubbleContent: the rounded fill, 12px corners and 12px by 8px padding, 14px text at a relaxed line height. - Reactions.
BubbleReactions: an optional Well Gray pill that overlaps the bubble's top or bottom edge, ringed in Card White.
Examples#
Variants
muted and outline carry a conversation. The rest exist for special cases: ghost for full-width content, destructive for a failed send. default is shown last because it fills with indigo.
import { Bubble, BubbleContent } from "@oration/canon/components/bubble";export function Variants() { const variants = [ { variant: "muted" as const, note: "The other party" }, { variant: "outline" as const, note: "Your side of the conversation" }, { variant: "secondary" as const, note: "Quiet Fill" }, { variant: "tinted" as const, note: "A pale primary tint" }, { variant: "ghost" as const, note: "No bubble, full width" }, { variant: "destructive" as const, note: "Failed to send" }, { variant: "default" as const, note: "Quiet Indigo fill" }, ]; return ( <div className="grid w-full gap-3 sm:grid-cols-2"> {variants.map((entry) => ( <div key={entry.variant} className="flex flex-col gap-1.5"> <span className="text-xs text-muted-foreground"> <span className="font-medium text-foreground"> {entry.variant} </span>{" "} {entry.note} </span> <Bubble variant={entry.variant}> <BubbleContent> The remittance is attached. </BubbleContent> </Bubble> </div> ))} </div> );}Reactions
BubbleReactions overlaps the bubble's edge in a Well Gray pill. Here it holds a toggle button with an icon and a count.
import { Bubble, BubbleContent, BubbleGroup, BubbleReactions } from "@oration/canon/components/bubble";import { cn } from "@oration/canon/lib/utils";import { ThumbsUpIcon } from "lucide-react";import * as React from "react";export function Reactions() { const [liked, setLiked] = React.useState(false); return ( <BubbleGroup className="w-full max-w-sm"> <Bubble variant="muted" className="mb-4"> <BubbleContent> New bank letter uploaded. Can you confirm before Friday? </BubbleContent> <BubbleReactions align="start"> <button type="button" aria-pressed={liked} aria-label={ liked ? "Remove thumbs up" : "React with thumbs up" } onClick={() => setLiked((value) => !value)} className={cn( "flex h-6 items-center gap-1 rounded-full px-2 text-xs outline-none transition-colors duration-150 hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/40", liked ? "text-foreground" : "text-muted-foreground", )} > <ThumbsUpIcon aria-hidden="true" className={cn("size-3", liked && "fill-current")} /> <span className="tabular-nums">{liked ? 2 : 1}</span> </button> </BubbleReactions> </Bubble> </BubbleGroup> );}Quick replies
BubbleContent rendered as a <button> makes each suggested reply tappable. Choosing one sends it and hides the rest.
import { Bubble, BubbleContent } from "@oration/canon/components/bubble";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function QuickReplies() { const [sent, setSent] = React.useState<string | null>(null); const replies = [ "Where is my payment?", "Update my bank details", "Talk to a person", ]; return ( <div className="flex w-full max-w-sm flex-col gap-3"> <Bubble variant="muted"> <BubbleContent> Hi, I'm Cedarline's supplier assistant. What can I help with? </BubbleContent> </Bubble> {sent ? ( <Bubble variant="outline" align="end"> <BubbleContent>{sent}</BubbleContent> </Bubble> ) : ( <div className="flex flex-col items-end gap-1.5"> {replies.map((reply) => ( <Bubble key={reply} variant="outline" align="end"> <BubbleContent render={ <button type="button" onClick={() => { setSent(reply); toast.add({ title: "Sent", description: reply, }); }} /> } > {reply} </BubbleContent> </Bubble> ))} </div> )} {sent ? ( <button type="button" onClick={() => setSent(null)} className="self-center rounded-sm text-xs text-muted-foreground hover:text-foreground" > Show the options again </button> ) : null} </div> );}Failed to send
The destructive tint marks the bubble, and the line under it says what happened and offers Retry.
import { Bubble, BubbleContent } from "@oration/canon/components/bubble";import { toast } from "@oration/canon/components/toast";import { RotateCwIcon } from "lucide-react";import * as React from "react";export function Failed() { const [status, setStatus] = React.useState<"failed" | "sent">("failed"); return ( <div className="flex w-full max-w-sm flex-col items-end gap-1"> <Bubble variant={status === "failed" ? "destructive" : "outline"} align="end" > <BubbleContent> Your W-9 upload link is on its way to ap@orchardstreet.com. </BubbleContent> </Bubble> {status === "failed" ? ( <span className="flex items-center gap-1.5 px-1 text-2xs text-destructive"> Not delivered. <button type="button" onClick={() => { setStatus("sent"); toast.add({ type: "success", title: "Message sent", }); }} className="inline-flex items-center gap-1 rounded-sm font-medium underline-offset-2 hover:underline" > <RotateCwIcon aria-hidden="true" className="size-3" /> Retry </button> </span> ) : ( <span className="px-1 text-2xs text-muted-foreground"> Delivered </span> )} </div> );}States#
import { Bubble, BubbleContent } from "@oration/canon/components/bubble";import { cn } from "@oration/canon/lib/utils";export function States() { const states = [ { label: "Rest", className: "" }, { label: "Hover", className: "bg-muted!" }, { label: "Focus visible", className: "border-ring! ring-3 ring-ring/50", }, ]; return ( <div className="grid w-full gap-4 sm:grid-cols-3"> {states.map((state) => ( <div key={state.label} className="flex flex-col items-start gap-2" > <span className="text-xs text-muted-foreground"> {state.label} </span> <Bubble variant="outline"> <BubbleContent render={<button type="button" tabIndex={-1} />} className={cn( "pointer-events-none", state.className, )} > Update my bank details </BubbleContent> </Bubble> </div> ))} </div> );}| State | Treatment |
|---|---|
| Rest | The variant's fill. Content wraps and never overflows the bubble. |
| Hover | Only when BubbleContent renders a button or link: the fill steps darker (for outline, to Well Gray). |
| Focus visible | Button or link content gets an indigo border and a 3px Focus Indigo ring at 50%. |
| Failed | The destructive variant: a 10% Signal Red tint with red text, and a Not delivered line with Retry beneath. |
| Sent, delivered, read | Not drawn by Bubble. Put it in a meta line under the group, as text with an icon. |
Behavior#
align="end"pushes a bubble to the right. Inside a Message withalign="end", bubbles follow automatically.- Bubbles are
w-fitup to 80% of their container; theghostvariant drops the fill and padding and runs full width. BubbleContenttakesrender, so a bubble can be a<button>(quick replies) or<a>. Only then does it get hover and focus styles.BubbleReactionsis absolutely positioned: give the bubble bottom margin (mb-4) so the pill doesn't overlap the next one. Buttons inside it drop its padding.- Bubbles don't animate by themselves. The product thread fades new messages up 8px from 0.98 on
spring.moderate.
Do and don't#
muted and your side in outline, so the thread stays calm and indigo stays for actions.Content#
- Write messages as you'd say them to a supplier: short sentences, the invoice number and amount when they matter.
- Quick replies are the supplier's words, in first person: Where is my payment?, Update my bank details.
- Sender names and timestamps go outside the bubble, in 12px and 11px Slate Meta.
- Reactions use icons with a count, not emoji; the suite doesn't put emoji in UI.
Accessibility#
- Bubbles are plain
<div>s. Wrap the conversation inrole="log"with a name so new messages are announced in order. - Give every group a visible or
sr-onlysender name. Screen readers don't see which side a bubble sits on. - Quick reply bubbles are real buttons through
render, with the reply text as their name. - Reaction buttons need
aria-pressedand a name that says the reaction and the action: React with thumbs up. - A failed bubble's state must be in text (Not delivered) next to it, not only in the red tint.
| Keys | Action |
|---|---|
| Tab | Moves to bubbles rendered as buttons or links, and to reaction buttons. |
| Enter | Sends a quick reply or toggles a reaction. |
Design tokens#
| Token | Used for |
|---|---|
--muted | muted fill; reactions pill |
--background | outline fill |
--border | outline stroke |
--secondary | secondary fill |
--primary | default fill and the tinted hue |
--destructive | destructive text and its 10% tint |
--card | Ring around the reactions pill |
--ring | Focus border and 3px ring on button content |
--radius-xl | 12px bubble corners |
API reference#
Bubble
The wrapper. Sets data-slot="bubble", data-variant and data-align.
Other props spread onto <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "secondary" | "muted" | "tinted" | "outline" | "ghost" | "destructive" | "default" | The fill. Use muted and outline in threads; default is a Quiet Indigo fill. |
align | "start" | "end" | "start" | Left for the other party, right for your side. |
BubbleContent
The fill that holds the text.
Other props spread onto Base UI useRender props for <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
render | ReactElement | (props, state) => ReactElement | No default | Render as a <button> or <a> to make the bubble interactive. |
BubbleGroup
A column of consecutive bubbles, 8px apart.
Other props spread onto <div>.
No props of its own.
BubbleReactions
A pill of reactions on the bubble's edge.
Other props spread onto <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
side | "top" | "bottom" | "bottom" | Which edge it overlaps. |
align | "start" | "end" | "end" | Which corner it sits near, 12px in. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
The default variant is a solid Quiet Indigo fill, which The Quiet Indigo Rule doesn't allow for message content. tinted also derives from --primary.
Bubble isn't imported anywhere in apps/web. The Contact Center chat thread hand-rolls bubbles with rounded-2xl and a tighter corner on the sender's side, bg-muted for the supplier and Card White with shadow-border for the agent.
The outline variant draws a CSS border on a light fill, while the product's outbound bubble uses the hairline lift.
There's no tail or grouped-corner treatment, and no built-in sender, time or delivery status.
The hover on interactive default bubbles is bg-primary/80, a lighter fill, where primary buttons darken by mixing 9% black.