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 Message from @elirobinson/ai-elements. It takes a UIMessage role directly, renders markdown through Streamdown, and carries branch navigation. This component still works and is removed on the next major.

Jamie2:04 PM
Can you summarise where the budget stands?
Assistant2:04 PM
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.
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

PropTypeDefaultDescription
avatarrequiredReactNodeThe avatar mark. Required — the system derives nothing from the variant.
actionsReactNodeTurn-level affordances, as a node.
namestringWho is speaking. This, not the avatar, is what identifies the turn.
timestampstringPre-formatted time string. Formatting is the product's call, not the system's.
variant"sent" | "received"receivedWhich 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, so name is 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 name is announced with no attribution at all. Pass name on any thread where speakers alternate.
  • timestamp is 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 Avatar at its md step — it carries ds-avatar ds-avatar--md so the 40px circle comes from Avatar.css and the system has one avatar scale. It is drawn as a --border-control outline 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 is aria-hidden and 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.