Skip to content

Bubble

A chat bubble with grouping and reactions for human conversations.

Category
Content
Adoption
Not used yet
import { Bubble } from "@oration/canon/components/bubble";
packages/canon/src/components/bubble.tsx
Halcyon Supply
Hi, we still haven't received the remittance for the Sep 25 run.
Invoice INV-20877, $6,410.00.
9:41 AM
Jordan Lee
Thanks for flagging it. INV-20877 was paid by ACH on Sep 25 and the remittance went to ap@halcyonsupply.com.
I've resent it just now.
9:43 AMDelivered
import { 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 BubbleContent rendered as a button.
  • For a message that failed to send, in the destructive tint 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

Indigo is for the view's primary action, selection, focus and labelled live state. Conversation bubbles are muted for the other party and outline for your side; the indigo default variant stays out of the thread.

The Label-Beside-Color Rule

Side and fill aren't enough to say who spoke. Every group carries the sender's name as text, and a failed bubble says Not delivered beside its red tint.

Anatomy#

Is the Friday run still on?
We ship Monday.
1
  1. Group. BubbleGroup: a column of consecutive bubbles from one sender, 8px apart.
  2. Bubble. Bubble: the wrapper that sets the variant and alignment, up to 80% of the thread's width.
  3. Content. BubbleContent: the rounded fill, 12px corners and 12px by 8px padding, 14px text at a relaxed line height.
  4. 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.

muted The other party
The remittance is attached.
outline Your side of the conversation
The remittance is attached.
secondary Quiet Fill
The remittance is attached.
tinted A pale primary tint
The remittance is attached.
ghost No bubble, full width
The remittance is attached.
destructive Failed to send
The remittance is attached.
default Quiet Indigo fill
The remittance is attached.
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.

New bank letter uploaded. Can you confirm before Friday?
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.

Hi, I'm Cedarline's supplier assistant. What can I help with?
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.

Your W-9 upload link is on its way to ap@orchardstreet.com.
Not delivered.
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#

Rest
Hover
Focus visible
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>    );}
States
StateTreatment
RestThe variant's fill. Content wraps and never overflows the bubble.
HoverOnly when BubbleContent renders a button or link: the fill steps darker (for outline, to Well Gray).
Focus visibleButton or link content gets an indigo border and a 3px Focus Indigo ring at 50%.
FailedThe destructive variant: a 10% Signal Red tint with red text, and a Not delivered line with Retry beneath.
Sent, delivered, readNot 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 with align="end", bubbles follow automatically.
  • Bubbles are w-fit up to 80% of their container; the ghost variant drops the fill and padding and runs full width.
  • BubbleContent takes render, so a bubble can be a <button> (quick replies) or <a>. Only then does it get hover and focus styles.
  • BubbleReactions is 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#

When will INV-20877 land?
Today, by ACH.
Do. Draw the other party in muted and your side in outline, so the thread stays calm and indigo stays for actions.
When will INV-20877 land?
Today, by ACH.
Don't. Use the default indigo fill for outbound messages. A thread of indigo bubbles outshouts the reply button.

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 in role="log" with a name so new messages are announced in order.
  • Give every group a visible or sr-only sender 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-pressed and 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.
Keyboard interactions
KeysAction
TabMoves to bubbles rendered as buttons or links, and to reaction buttons.
EnterSends a quick reply or toggles a reaction.

Design tokens#

Design tokens
TokenUsed for
--mutedmuted fill; reactions pill
--backgroundoutline fill
--borderoutline stroke
--secondarysecondary fill
--primarydefault fill and the tinted hue
--destructivedestructive text and its 10% tint
--cardRing around the reactions pill
--ringFocus border and 3px ring on button content
--radius-xl12px bubble corners

API reference#

Bubble

The wrapper. Sets data-slot="bubble", data-variant and data-align.

Other props spread onto <div>.

Props of Bubble
PropTypeDefaultDescription
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>.

Props of BubbleContent
PropTypeDefaultDescription
renderReactElement | (props, state) => ReactElementNo defaultRender 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>.

Props of BubbleReactions
PropTypeDefaultDescription
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.