Skip to content

Message

A conversation message with avatar, header and footer for human channels.

Category
Content
Adoption
Not used yet
import { Message } from "@oration/canon/components/message";
packages/canon/src/components/message.tsx
HS
Halcyon Supply
We still haven't received the remittance for the Sep 25 run.
It's for INV-20877, $6,410.00.
9:41 AM
JL
Jordan Lee
INV-20877 went out by ACH on Sep 25. I've resent the remittance to ap@halcyonsupply.com.
9:43 AMRead
import { Avatar, AvatarFallback } from "@oration/canon/components/avatar";import { Bubble, BubbleContent } from "@oration/canon/components/bubble";import {  Message,  MessageAvatar,  MessageContent,  MessageFooter,  MessageHeader,} from "@oration/canon/components/message";import { CheckCheckIcon } from "lucide-react";export function Hero() {    return (        <div            role="log"            aria-label="Chat with Halcyon Supply"            className="flex w-full max-w-lg flex-col gap-5 rounded-xl bg-card p-4 shadow-border"        >            <Message>                <MessageAvatar>                    <Avatar>                        <AvatarFallback className="text-xs">HS</AvatarFallback>                    </Avatar>                </MessageAvatar>                <MessageContent>                    <MessageHeader>Halcyon Supply</MessageHeader>                    <Bubble variant="muted">                        <BubbleContent>                            We still haven't received the remittance for the Sep                            25 run.                        </BubbleContent>                    </Bubble>                    <Bubble variant="muted">                        <BubbleContent>                            It's for INV-20877, $6,410.00.                        </BubbleContent>                    </Bubble>                    <MessageFooter className="tabular-nums">                        9:41 AM                    </MessageFooter>                </MessageContent>            </Message>            <Message align="end">                <MessageAvatar>                    <Avatar>                        <AvatarFallback className="text-xs">JL</AvatarFallback>                    </Avatar>                </MessageAvatar>                <MessageContent>                    <MessageHeader>Jordan Lee</MessageHeader>                    <Bubble variant="outline">                        <BubbleContent>                            INV-20877 went out by ACH on Sep 25. I've resent the                            remittance to ap@halcyonsupply.com.                        </BubbleContent>                    </Bubble>                    <MessageFooter className="gap-1 tabular-nums">                        9:43 AM                        <CheckCheckIcon                            aria-hidden="true"                            className="size-3 text-info"                        />                        Read                    </MessageFooter>                </MessageContent>            </Message>        </div>    );}

Usage#

Message lays out one turn of a human conversation: an avatar, a header with the sender, the content (usually one or more Bubbles) and a footer with the time or delivery state, mirrored for your own side with align="end". It's for Contact Center and ticket threads across chat, SMS, WhatsApp and email, and is distinct from the AI message used by the Copilot. It is experimental; the product's threads still hand-roll this layout. The thing people get wrong is identity: alignment tells sighted people who spoke, but the header must say it in words.

When to use

  • For a turn in a chat-style thread: web widget chat, SMS, WhatsApp, social DMs, with Bubbles inside.
  • For an email in a ticket or conversation, with a ghost Bubble so the body runs full width.
  • For a message that carries files, with Attachments inside the content column.
  • For several consecutive messages from one sender, under one header and footer.

When not to use

  • For an AI assistant's turn, with streaming, actions and branches. Use AI message
  • For the scrolling container that keeps the latest message in view. Use Message scroller
  • For internal notes between agents. Use a tint well so they never read as sent. Use Well
  • For a live call transcript. Use Live transcript
  • For an activity entry such as Priya assigned this ticket. Use Timeline

The Label-Beside-Color Rule

Side and fill never carry identity or state alone. The header names the sender and the footer says Delivered, Read or Not delivered in words.

The Quiet Indigo Rule

Messages are muted for the other party and outline for your side. Indigo stays for the reply button and the viewer's own markers, such as an @mention of you.

Anatomy#

WZ
Wen Zhou
Price list attached.
10:02 AM
  1. Message. Message: a row with 8px between avatar and content, reversed for align="end".
  2. Avatar. MessageAvatar: a round slot at least 32px wide, aligned to the bottom so it sits beside the last bubble.
  3. Header. MessageHeader: the sender's name in 12px medium Slate Meta, inset 12px to line up with bubble text.
  4. Content. MessageContent: a column of bubbles, attachments and anything else, 10px apart.
  5. Footer. MessageFooter: the time and delivery state in 12px Slate Meta, right-aligned on your side.

Examples#

Alignment

The other party at the start in muted bubbles, your side at the end with align="end" and outline bubbles. Bubbles follow the message's alignment.

Orchard Street Bakery
Can I change the account you pay us into?
Aisha Bello
Yes. Upload a signed bank letter and we'll verify it by phone.
import { Bubble, BubbleContent } from "@oration/canon/components/bubble";import { Message, MessageContent, MessageGroup, MessageHeader } from "@oration/canon/components/message";export function Alignment() {    return (        <MessageGroup className="w-full max-w-md gap-4">            <Message>                <MessageContent>                    <MessageHeader>Orchard Street Bakery</MessageHeader>                    <Bubble variant="muted">                        <BubbleContent>                            Can I change the account you pay us into?                        </BubbleContent>                    </Bubble>                </MessageContent>            </Message>            <Message align="end">                <MessageContent>                    <MessageHeader>Aisha Bello</MessageHeader>                    <Bubble variant="outline">                        <BubbleContent>                            Yes. Upload a signed bank letter and we'll verify it                            by phone.                        </BubbleContent>                    </Bubble>                </MessageContent>            </Message>        </MessageGroup>    );}

Consecutive messages

Three bubbles under one header and one footer, with the content gap tightened to 4px so they read as one turn.

NF
Northwind Freight
Morning. Two quick questions about the Friday run.
Is INV-21004 in it?
And will it be ACH or wire this time?
8:15 AM
import { Avatar, AvatarFallback } from "@oration/canon/components/avatar";import { Bubble, BubbleContent } from "@oration/canon/components/bubble";import {  Message,  MessageAvatar,  MessageContent,  MessageFooter,  MessageHeader,} from "@oration/canon/components/message";export function Grouped() {    const lines = [        "Morning. Two quick questions about the Friday run.",        "Is INV-21004 in it?",        "And will it be ACH or wire this time?",    ];    return (        <Message className="max-w-md">            <MessageAvatar>                <Avatar>                    <AvatarFallback className="text-xs">NF</AvatarFallback>                </Avatar>            </MessageAvatar>            <MessageContent className="gap-1">                <MessageHeader className="pb-1">                    Northwind Freight                </MessageHeader>                {lines.map((line) => (                    <Bubble key={line} variant="muted">                        <BubbleContent>{line}</BubbleContent>                    </Bubble>                ))}                <MessageFooter className="pt-1 tabular-nums">                    8:15 AM                </MessageFooter>            </MessageContent>        </Message>    );}

Email

A ghost bubble lets the body run full width, and the header carries the address and date. The avatar is top-aligned and cancels the footer shift. Quoted text stays behind a toggle.

AB
Aisha Belloaisha@northwindfreight.comSep 26, 4:12 PM
Hi Cedarline team, The Sep 25 payment for INV-20931 came in $1,200 short. Our new bank letter is attached in case that's related. Thanks, Aisha
bank-letter.pdf212 KB
import {  Attachment,  AttachmentContent,  AttachmentDescription,  AttachmentMedia,  AttachmentTitle,  AttachmentTrigger,} from "@oration/canon/components/attachment";import { Avatar, AvatarFallback } from "@oration/canon/components/avatar";import { Bubble, BubbleContent } from "@oration/canon/components/bubble";import { Button } from "@oration/canon/components/button";import {  Message,  MessageAvatar,  MessageContent,  MessageFooter,  MessageHeader,} from "@oration/canon/components/message";import { toast } from "@oration/canon/components/toast";import { FileTextIcon } from "lucide-react";import * as React from "react";export function Email() {    const [quoted, setQuoted] = React.useState(false);    return (        <article className="w-full max-w-lg rounded-xl bg-card p-4 shadow-border">            <Message>                <MessageAvatar className="self-start group-has-data-[slot=message-footer]/message:translate-y-0">                    <Avatar>                        <AvatarFallback className="text-xs">AB</AvatarFallback>                    </Avatar>                </MessageAvatar>                <MessageContent>                    <MessageHeader className="flex-wrap gap-x-2 text-[13px]">                        <span className="font-medium text-foreground">                            Aisha Bello                        </span>                        <span className="font-normal">                            aisha@northwindfreight.com                        </span>                        <span className="ml-auto font-normal tabular-nums">                            Sep 26, 4:12 PM                        </span>                    </MessageHeader>                    <Bubble variant="ghost">                        <BubbleContent className="whitespace-pre-line">                            {                                "Hi Cedarline team,\n\nThe Sep 25 payment for INV-20931 came in $1,200 short. Our new bank letter is attached in case that's related.\n\nThanks,\nAisha"                            }                        </BubbleContent>                    </Bubble>                    <Attachment size="sm">                        <AttachmentTrigger                            aria-label="Open bank-letter.pdf"                            onClick={() =>                                toast.add({                                    title: "bank-letter.pdf",                                    description: "Opens the preview.",                                })                            }                        />                        <AttachmentMedia>                            <FileTextIcon aria-hidden="true" />                        </AttachmentMedia>                        <AttachmentContent>                            <AttachmentTitle>bank-letter.pdf</AttachmentTitle>                            <AttachmentDescription>                                212 KB                            </AttachmentDescription>                        </AttachmentContent>                    </Attachment>                    <MessageFooter className="flex-col items-start gap-2">                        <Button                            type="button"                            variant="ghost"                            size="xs"                            className="-ml-2"                            aria-expanded={quoted}                            onClick={() => setQuoted((value) => !value)}                        >                            {quoted ? "Hide quoted text" : "Show quoted text"}                        </Button>                        {quoted ? (                            <p className="border-l-2 border-border pl-3 text-13 font-normal whitespace-pre-line">                                {                                    "On Sep 25, Cedarline wrote:\nYour payment of $17,220.00 was sent by ACH."                                }                            </p>                        ) : null}                    </MessageFooter>                </MessageContent>            </Message>        </article>    );}

With an attachment

An Attachment sits in the content column under the bubble and aligns with it.

Tomás Ferreira
Here's the corrected remittance.
remittance-inv-20931.pdf64 KB
11:20 AM, delivered
import {  Attachment,  AttachmentContent,  AttachmentDescription,  AttachmentMedia,  AttachmentTitle,  AttachmentTrigger,} from "@oration/canon/components/attachment";import { Bubble, BubbleContent } from "@oration/canon/components/bubble";import { Message, MessageContent, MessageFooter, MessageHeader } from "@oration/canon/components/message";import { toast } from "@oration/canon/components/toast";import { FileTextIcon } from "lucide-react";export function WithAttachment() {    return (        <Message align="end" className="max-w-md">            <MessageContent>                <MessageHeader>Tomás Ferreira</MessageHeader>                <Bubble variant="outline">                    <BubbleContent>                        Here's the corrected remittance.                    </BubbleContent>                </Bubble>                <Attachment size="sm">                    <AttachmentTrigger                        aria-label="Open remittance-inv-20931.pdf"                        onClick={() =>                            toast.add({                                title: "remittance-inv-20931.pdf",                                description: "Opens the preview.",                            })                        }                    />                    <AttachmentMedia>                        <FileTextIcon aria-hidden="true" />                    </AttachmentMedia>                    <AttachmentContent>                        <AttachmentTitle>                            remittance-inv-20931.pdf                        </AttachmentTitle>                        <AttachmentDescription>64 KB</AttachmentDescription>                    </AttachmentContent>                </Attachment>                <MessageFooter className="tabular-nums">                    11:20 AM, delivered                </MessageFooter>            </MessageContent>        </Message>    );}

States#

States
StateTreatment
Inboundalign="start": avatar left, content left, muted bubbles.
Outboundalign="end": the row reverses, content and footer align right, outline bubbles.
GroupedSeveral bubbles share one header and footer. Tighten the content gap with gap-1.
EmailA ghost bubble drops the padding, and the header and footer lose their 12px inset to match.
FailedA destructive bubble and a footer that says Not delivered with Retry.

Behavior#

  • align="end" sets data-align="end": the row reverses and every direct child of the content column aligns right, including Bubbles.
  • The avatar aligns to the bottom of the row. When the message has a footer, it shifts up 32px to sit beside the last bubble rather than the footer.
  • Headers and footers inset 12px to line up with bubble text, and drop the inset when the message contains a ghost bubble.
  • Message is layout only: no state, no roles, no motion. The product thread animates new messages in on spring.moderate.

Do and don't#

Priya Raman Internal note

Check the credit memo first.

Do. Show internal notes as a tint well with a note icon and Internal note, so they can't be mistaken for a reply.
Priya Raman
Check the credit memo first.
Don't. Render a note as an outbound message. It reads as sent to the supplier.

Content#

  • Headers are the sender's display name: Halcyon Supply, Jordan Lee. Add the email address only on email.
  • Footers are the time and delivery state, as words: 9:43 AM, Read, Not delivered.
  • Times are in the viewer's time zone and tabular figures. Older messages add the date: Sep 26, 4:12 PM.
  • Don't join header parts with dots; use gaps or a comma.

Accessibility#

  • Message renders plain <div>s. Wrap the thread in role="log" with a name, and consider an <article> per email.
  • Keep the sender's name in the header on every group, visible or sr-only. Alignment and color aren't announced.
  • Avatars are decorative next to a named header; don't give them alt text that repeats the name.
  • Delivery icons need their word beside them (Read, Delivered), not only a check mark.
  • Controls inside messages, such as Show quoted text, need aria-expanded when they reveal content.

Design tokens#

Design tokens
TokenUsed for
--mutedAvatar slot fill
--muted-foregroundHeader and footer text
text-xs12px header and footer
rounded-fullRound avatar slot

API reference#

Message

One turn. Sets data-slot="message" and data-align.

Other props spread onto <div>.

Props of Message
PropTypeDefaultDescription
align"start" | "end""start"Start for the other party, end for your side.

MessageAvatar

The avatar slot, bottom-aligned, at least 32px wide.

Other props spread onto <div>.

No props of its own.

MessageContent

The content column, 10px between children.

Other props spread onto <div>.

No props of its own.

MessageHeader

The sender line above the content.

Other props spread onto <div>.

No props of its own.

MessageFooter

The time and status line below the content.

Other props spread onto <div>.

No props of its own.

MessageGroup

A column of messages, 8px apart.

Other props spread onto <div>.

No props of its own.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

Message isn't imported anywhere in apps/web. The Contact Center chat and email threads hand-roll their own layout.

The avatar's upward shift is a fixed 32px (-translate-y-8) that assumes a one-line footer and the default 10px content gap. A wrapping footer or a tighter gap, as in consecutive messages, moves the avatar off the last bubble, and a top-aligned avatar (self-start, as in the email example) is pushed above the message unless you also pass group-has-data-[slot=message-footer]/message:translate-y-0.

MessageHeader and MessageFooter merge classes with cn from the cn package, so text-13 passed to them reads as a color. Use text-[13px].

The email thread in the product draws each email as its own card; Message has no email layout beyond a ghost bubble.