Message
A conversation message with avatar, header and footer for human channels.
- Status
- Experimental
- Level
- Molecule
- Category
- Content
- Adoption
- Not used yet
import { Message } from "@oration/canon/components/message";packages/canon/src/components/message.tsximport { 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
ghostBubble 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
The Quiet Indigo Rule
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#
- Message.
Message: a row with 8px between avatar and content, reversed foralign="end". - Avatar.
MessageAvatar: a round slot at least 32px wide, aligned to the bottom so it sits beside the last bubble. - Header.
MessageHeader: the sender's name in 12px medium Slate Meta, inset 12px to line up with bubble text. - Content.
MessageContent: a column of bubbles, attachments and anything else, 10px apart. - 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.
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.
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> );}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.
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.
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#
| State | Treatment |
|---|---|
| Inbound | align="start": avatar left, content left, muted bubbles. |
| Outbound | align="end": the row reverses, content and footer align right, outline bubbles. |
| Grouped | Several bubbles share one header and footer. Tighten the content gap with gap-1. |
A ghost bubble drops the padding, and the header and footer lose their 12px inset to match. | |
| Failed | A destructive bubble and a footer that says Not delivered with Retry. |
Behavior#
align="end"setsdata-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
ghostbubble. - 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.
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 inrole="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-expandedwhen they reveal content.
Design tokens#
| Token | Used for |
|---|---|
--muted | Avatar slot fill |
--muted-foreground | Header and footer text |
text-xs | 12px header and footer |
rounded-full | Round avatar slot |
API reference#
Message
One turn. Sets data-slot="message" and data-align.
Other props spread onto <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
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.
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.