ChatMessage
A single turn in a conversation: avatar, optional attribution, content, and actions.
import { ChatMessage } from '@elirobinson/react/components/ai/ChatMessage';Styles: @elirobinson/react/styles/ai/ChatMessage.css — already included when you import @elirobinson/react/styles.css.
Deprecated. Use
Messagefrom@elirobinson/ai-elements. It takes aUIMessagerole directly, renders markdown through Streamdown, and carries branch navigation. This component still works and is removed on the next major.
Show code
import { ChatMessage } from '@elirobinson/react/components/ai/ChatMessage';
import { Button } from '@elirobinson/react/components/atoms/Button';
export default function Basic() {
return (
<div className="demo-col">
<ChatMessage variant="sent" avatar="JR" name="Jamie" timestamp="2:04 PM">
Can you summarise where the budget stands?
</ChatMessage>
<ChatMessage
avatar="M"
name="Assistant"
timestamp="2:04 PM"
actions={
<>
<Button variant="ghost" size="sm">
Copy
</Button>
<Button variant="ghost" size="sm">
Retry
</Button>
</>
}
>
Two thirds of the quarter's budget is spent with five weeks left. Nothing is over yet, but
the software line is running ahead of the others.
</ChatMessage>
</div>
);
}When to use it
ChatMessage is one turn in a conversation: an avatar column, optional attribution, the
content, and optional turn-level affordances. Put turns inside a ChatThread, which owns
the announcement contract; the message itself is layout and typography.
variant is received by default. A sent turn reads as settled through --fg-2 — a
real foreground token that follows the theme and holds its contrast — never through
opacity, which would drop body copy to roughly 3.4:1 and fail SC 1.4.3 while looking
merely quiet.
Two required decisions
avatar is a required node, and there is deliberately no fallback derived from
variant. A system that invented a mark for the assistant would be picking a product's
voice for it, and a system that invented one for the person would be guessing at their
identity. Pass initials, a glyph, an image — whatever the product actually knows.
actions is a node, not an [{ label, onClick }] array. An array shape would mean the
system owns how an action renders, which button variant it gets, and what happens when a
product needs a menu, a link, or a copy control with its own state. A node owns none of
that: you pass the buttons you already have.
Coming from a role union?
There is no role prop, and there is no ChatRole type. An earlier draft of this
component had 'user' | 'assistant' | 'system', and it was cut rather than extended.
An author enum is a domain model, and the design system does not own your domain. Three
members is wrong for a product with four authors and wrong again for one with two —
name plus avatar is wrong for none of them, because it asks you for what you already
have rather than asking you to translate into our vocabulary and then translate back.
Cutting it also removed the thing that made a union attractive in the first place:
deriving the avatar from the role. That derivation is why avatar is required — a mark
the system picks for you is a pure-ink glyph, and a pure-ink glyph disappears against
--ink-950 in dark mode.
So keep your union. It is yours, it belongs in your state, and you translate it where you render:
const AUTHOR = {
user: { variant: 'sent', name: 'Eli', avatar: 'E' },
assistant: { variant: 'received', name: 'Assistant', avatar: '◆' },
admin: { variant: 'received', name: 'Support', avatar: 'S' },
} as const;
{
messages.map((message) => (
<ChatMessage key={message.id} {...AUTHOR[message.role]}>
{message.text}
</ChatMessage>
));
}A fourth author is one line in that object, on your release schedule rather than ours. And
admin sharing a side with assistant becomes a product decision you can see, instead of
a fact buried in a union somebody else versions.
The one thing not to do is push the table back down into the component. A ChatMessage
that took a role-to-props map would be the same domain model, one indirection further
away — and then your fourth author would need a release of this package.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
avatarrequired | ReactNode | — | The avatar mark. Required — the system derives nothing from the variant. |
actions | ReactNode | — | Turn-level affordances, as a node. |
name | string | — | Who is speaking. This, not the avatar, is what identifies the turn. |
timestamp | string | — | Pre-formatted time string. Formatting is the product's call, not the system's. |
variant | "sent" | "received" | received | Which side of the conversation this turn is. Default 'received'. |
Also accepts all HTMLAttributes<HTMLDivElement> props.
Accessibility
- The avatar wrapper is
aria-hidden="true"— always. It's a decorative restatement of the speaker, sonameis the single thing that identifies a turn to a screen reader. That keeps one turn from being announced twice, and keeps identification off a node a consumer may pass as a bare glyph. - Because the avatar carries nothing, a turn with no
nameis announced with no attribution at all. Passnameon any thread where speakers alternate. timestampis a pre-formatted string. Formatting is the product's call — locale, relative versus absolute, seconds or not — so the system never parses or reformats it.- The avatar frame is an
Avatarat itsmdstep — it carriesds-avatar ds-avatar--mdso the 40px circle comes fromAvatar.cssand the system has one avatar scale. It is drawn as a--border-controloutline over that fill rather than a solid ink fill, so the mark inside survives both themes. It is not 44px: the 44px floor is scoped to primary interactive controls, and this frame isaria-hiddenand not focusable. - Keyboard: the turn itself is not focusable. Anything tabbable comes from the node
you pass as
actions. - The ref forwards to the outer
<div>.
Do
- Pass name on both sides of a conversation so each turn is attributed once.
- Format the timestamp before passing it, in the reader’s locale.
- Pass real buttons as actions, styled the way the rest of your product styles them.
- Use variant="sent" for the person and the default for the assistant.
Don't
- Expect an avatar to appear on its own — it is required, with no derived fallback.
- Rely on the avatar to say who is speaking; it is hidden from screen readers.
- Dim a settled turn with opacity; --fg-2 is what carries that state.
- Pass an array of action descriptors — actions takes nodes.
Product theming
The assistant mark reads --product-signal-fg with a fallback to the system's
--status-warning-fg, so a product can own that hue without forking the token set. The
convention is documented in docs/agents/product-token-layer.md; the fallback means a
product that declares nothing gets Miltinson Amber and measured contrast.