ChatMessage

A single message in a chat transcript — supports user bubbles, assistant prose, optional avatars, and an action bar for copy/regenerate/feedback affordances.

Installation

pnpm add @wandercom/design-system-web

Usage

5 lines
import { ChatMessage } from '@wandercom/design-system-web/ui/chat-message';

export function Example() {
  return <ChatMessage from="user">Hello</ChatMessage>;
}

ChatMessage is a thin wrapper around the compound primitives. Reach for the primitives directly when you need fine-grained control.

The author is named from (not role) so the JSX prop doesn't collide with the HTML/ARIA role attribute.

Preview

Loading example...

Authors

The from prop drives alignment and visual treatment.

  • user — right-aligned bubble with surface-secondary background, rounded-xl corners, and body-long typography. Capped at ~36rem and constrained to leave room for the assistant on the opposite side.
  • assistant — flush-left prose, no bubble, full transcript width. Pair with the Markdown primitive when rendering rich responses.
2 lines
<ChatMessage from="user">Change the hero description.</ChatMessage>
<ChatMessage from="assistant">The hero description has been updated.</ChatMessage>

Compound API

For more control, compose the primitives directly:

15 lines
import {
  ChatMessageRoot,
  ChatMessageAvatar,
  ChatMessageContent,
  ChatMessageMeta,
  ChatMessageActions,
  ChatMessageAction,
} from '@wandercom/design-system-web/ui/chat-message';

<ChatMessageRoot from="user">
  <ChatMessageContent from="user">
    Change background image to this.
    <ChatMessageMeta>1 attachment</ChatMessageMeta>
  </ChatMessageContent>
</ChatMessageRoot>;

Markdown content

When the body is Markdown, opt in via markdown so ChatMessageContent skips its default Text wrapper and lets the Markdown primitive own block typography. ChatMessage does not import Markdown itself — wrap the children in your call site.

6 lines
import { ChatMessage } from '@wandercom/design-system-web/ui/chat-message';
import { Markdown } from '@wandercom/design-system-web/ui/markdown';

<ChatMessage from="assistant" markdown>
  <Markdown>{response}</Markdown>
</ChatMessage>;

Live announcements

Mark the assistant's most recent streaming message as live so screen readers announce it once it settles. The message renders with aria-live="polite" and aria-atomic="false" — partial token updates queue without firing mid-stream announcements, and the final text is read once additions stop.

3 lines
<ChatMessage from="assistant" live markdown>
  <Markdown>{streamingContent}</Markdown>
</ChatMessage>

Set live only on the message that is actively arriving. Setting it on every message defeats the point — screen readers will re-announce on every change. Once the message is stable, drop the prop or move it to the next arriving turn.

The parent ChatContainer deliberately does not declare itself a role="log" / aria-live region, because doing so would re-announce on every streamed token across the whole transcript.

Actions

Render a horizontal action bar below the message via the actions prop or compose ChatMessageActions and ChatMessageAction directly. Each action is an icon-only ghost button with a tooltip and matching aria-label.

12 lines
import { IconClipboard } from '@central-icons-react/round-outlined-radius-2-stroke-1.5/IconClipboard';
import { IconReplay } from '@central-icons-react/round-outlined-radius-2-stroke-1.5/IconReplay';

<ChatMessage
  from="assistant"
  actions={[
    { label: 'Copy', icon: <IconClipboard />, onClick: copy },
    { label: 'Regenerate', icon: <IconReplay />, onClick: regen },
  ]}
>
  The hero description has been updated.
</ChatMessage>;

Props

ChatMessage

from

'user' | 'assistant'
Author of the message. Drives alignment and styling.

children

ReactNode
Body content rendered inside the message.

avatar?

ReactNode
Optional avatar node. Rendered before the content for assistant messages, after the content for user messages.

actions?

Array<{ label: string; icon: ReactNode; onClick?: () => void; key?: string }>
Optional list of actions rendered in a horizontal bar below the message. Each action becomes an icon-only ghost button with a tooltip.

markdown?

boolean
When true, signals that children render Markdown via the `Markdown` primitive — strips the default `Text` wrapper so `Markdown` owns typography. Defaults to false.

live?

boolean
When true, marks this message as a freshly-arriving turn. Renders the message with `aria-live="polite"` and `aria-atomic="false"` so screen readers announce the settled text once additions stop. Set only on the most recent streaming message; defaults to false.

contentClassName?

string
Additional classes for the inner `ChatMessageContent`.

className?

string
Additional classes for the root wrapper.

ChatMessageRoot

from

'user' | 'assistant'
Author of the message. Drives alignment via `data-from` and the variant classes.

live?

boolean
When true, applies `aria-live="polite"` and `aria-atomic="false"` to this message so screen readers announce the settled text after streaming stops. Defaults to false.

asChild?

boolean
Renders the immediate child and merges props onto it instead of rendering a wrapper `<div>`. Defaults to false.

className?

string
Additional classes for the root wrapper.

ChatMessageContent

from

'user' | 'assistant'
Drives the bubble vs. prose visual treatment.

markdown?

boolean
When true, no inner `Text` wrapper is rendered so the caller’s `Markdown` component owns block typography. Defaults to false.

asChild?

boolean
Renders the immediate child and merges props onto it instead of rendering a wrapper `<div>`. Defaults to false.

className?

string
Additional classes.

ChatMessageAvatar

Forwards through to the shared Avatar primitive with size="sm" as the default and shrink-0 applied. Accepts the full Avatar prop surface (src, alt, fallback, size, className).

ChatMessageMeta

children

ReactNode
Secondary context rendered as a tertiary-color `body-long` span (e.g. attachment counts, timestamps).

className?

string
Additional classes.

ChatMessageActions

children

ReactNode
One or more `ChatMessageAction` children rendered in a horizontal row below the message.

className?

string
Additional classes for the action bar.

ChatMessageAction

label

string
Visible tooltip and accessible label for the icon-only action.

icon

ReactNode
Icon node rendered inside the button.

tooltip?

boolean
When true, wraps the trigger in a tooltip showing `label`. Defaults to true.

onClick?

(event: MouseEvent) => void
Click handler.

Accessibility

  • Every action is a real <button> (via the shared Button primitive) with both aria-label and a tooltip set from label. Never ship an action without a label.
  • Live-region semantics are scoped to individual arriving messages via the live prop, so streaming tokens don't fire announcements on every keystroke. The parent ChatContainer deliberately does not declare itself a role="log" / aria-live region. ChatThinking carries its own aria-live="polite" for the awaiting state.
  • The user/assistant distinction is exposed as data-from on the root and content elements for downstream styling and testing.

Keyboard interaction:

  • Tab / Shift+Tab — Move focus through actions.
  • Space / Enter — Activate the focused action.
  • Escape — Dismisses any open tooltip.
ChatMessage