PixelDosa UI

Message

One turn of a conversation, with reasoning, tool calls, citations and the answer kept together in the order the work happened.

9:41
Why did churn go up in August?
Assistant

Compare August churn with the prior three months

Split churned accounts by onboarding progress

Compare August churn with the prior three months

Split churned accounts by onboarding progress

  1. Runningchurn_by_month.sql
  2. Readingonboarding_events (Aug)

Installation

bash
npx shadcn@latest add @pixeldosa/message

The example above also uses AI Action Toolbar, Inline Citations, Reasoning Stream and Tool Call Card. To copy it as-is, install everything it needs:

bash
npx shadcn@latest add @pixeldosa/message @pixeldosa/ai-action-toolbar @pixeldosa/inline-citations @pixeldosa/reasoning-stream @pixeldosa/tool-call-card

Requires `cn` at `@/lib/utils`. Bring your own markdown renderer and wrap its output in `MessageContent`. Set `status` from the stream itself: 'done' only when the stream closes, 'stopped' when the user aborted.

Usage

"use client";

import { Message, MessageContent } from "@/components/ui/message";

<Message role="user">Why did churn go up in August?</Message>

<Message
  role="assistant"
  author="Assistant"
  status={stream.status}
  error={stream.error}
  onRetry={retry}
  onContinue={resume}
  actions={<AIActionToolbar actions={answerActions} onAction={handle} />}
>
  <ReasoningStream steps={stream.reasoning} isStreaming={stream.phase === "reasoning"} />
  <ToolCallGroup calls={stream.toolCalls} />
  <MessageContent>
    <Markdown>{stream.text}</Markdown>
  </MessageContent>
</Message>

Why it exists

An AI answer isn't only text. It's the reasoning, what the model looked up and what it concluded, and those belong to one turn. Split them into separate rows and the reading order breaks, screen readers lose the thread, and it's no longer clear which tool calls produced which answer.

Features

  • The turn is the container. Children render in order: reasoning, tools, then the answer.
  • Real streaming, no fake typing. Text appears as chunks arrive, with a small quantized cursor at the end.
  • No acting on half an answer. Actions stay hidden until the message is done.
  • Nothing is thrown away. A failed turn keeps what arrived, says why and offers Retry. A stopped turn is labelled incomplete, with Continue.
  • Room to read. User turns are compact bubbles. Assistant turns use the full width for code, tables and long answers.
  • Any markdown renderer. MessageContent styles the HTML it produces.

Statuses

statusLooks likeOffers
streamingText as it arrives, plus the cursorNothing yet
doneFinal answeractions
failedPartial text, dimmed, plus an errorRetry
stoppedPartial text, labelled incompleteContinue, Start over

Examples

Mapping stream events, whatever your runtime:

const status =
  aborted ? "stopped" : error ? "failed" : streamClosed ? "done" : "streaming";

A user message with edit, shown on hover and always visible on touch:

<Message role="user" actions={<button onClick={edit}>Edit</button>}>
  {text}
</Message>

Props

Message

PropTypeDescription
role"user" | "assistant"Required.
status"streaming" | "done" | "failed" | "stopped"Assistant turns. Defaults to "done".
authorReactNodeAlso names the article for screen readers when it's a string.
avatarReactNodeAssistant turns.
metaReactNodeTimestamp or similar.
actionsReactNodeShown when done, or on hover and focus for user turns.
errorstringShown when failed.
onRetry() => void
onContinue() => voidOffered when stopped.
showCursorbooleanThe streaming cursor. Defaults to true; turn it off while reasoning or a tool call already shows progress.

MessageContent

A div with typography for paragraphs, lists, headings, links, inline code and quotes.

Accessibility

  • Each turn is an <article> named "You said" or "Assistant said".
  • aria-busy is set while streaming, so the turn isn't announced piece by piece.
  • Errors render in an alert region.
  • The streaming cursor is decorative and static under reduced motion.

When not to use

Use Message Scroller for the list itself: scrolling, jumping to the latest message and announcements. For a single generated artifact with no conversation, use Prompt Composer and show the result directly.

why it's built this way

Engineering Notes

Designed as the turn, not the text bubble. An assistant reply contains reasoning, tool calls, citations and prose, and treating those as siblings of the message breaks both reading order and accessibility. Here they are children of one `<article>`, named 'You said' or '<author> said' and marked `aria-busy` while streaming, so assistive tech treats the turn as one unit that is still arriving. Status is caller-owned because only the stream knows it. Each status has distinct UI, and none discards content: failed keeps the partial answer (dimmed) with an actionable error in an alert region and Retry; stopped labels the answer incomplete with Continue and Start over. Actions on assistant turns render only once the status is 'done', because copying or applying half an answer is an error the interface shouldn't invite. The user bubble keeps its actions hidden until hover or focus-within, and always shows them on touch devices via `(hover: none)`. Assistant turns have no bubble so code, tables and long prose get the full width. `MessageContent` carries typography for the HTML any markdown renderer emits, so the component stays renderer-agnostic and dependency-free.

how it moves, and when it doesn't

Motion Notes

The only moving part is the streaming cursor: one quantized cell pulsing on the shared `pd-cell-pulse` keyframe, the system's machine-busy grammar, rather than a blinking text caret, which would imply typing. It disappears the moment status leaves 'streaming', and the answer holds still once done. Actions on the user's own message fade in at `--pd-duration-fast` on hover or focus. There's no entrance animation on the message itself, because the list decides how new turns arrive. Under prefers-reduced-motion the cursor is a static cell and the fade is instant.