Message
One turn in a transcript. The speaker is declared once on the row, and every part reads it back off the DOM.
Usage
import {
Message,
MessageActions,
MessageAvatar,
MessageContent,
MessageList,
} from "@kanzo-tech/ai";<MessageList>
<Message role="user">
<MessageAvatar name="Ravenna Sarkis" />
<MessageContent>Is the quarry contract properly crewed?</MessageContent>
</Message>
</MessageList>Nothing here is stateful. A Message is markup — an <li> with a role on it — which is why the
whole family renders on the server and costs nothing on the client.
The role is declared once
role is "user", "assistant" or "system", it goes on the row, and it is mirrored to
data-role. Every part below reads it back through a group selector: MessageContent picks up the
bubble on a user turn, MessageAvatar hides itself on a system note, and MessageActions packs to
the reading end.
Two surfaces, three roles. A system note is not a speaker, so it takes neither the bubble nor
the page — it is an aside about the conversation. Compose an Alert
inside the row and you get the shape the library already ships: a wash, a border, an icon slot,
role="status", and the ability to wrap.
There used to be a third surface here, a centred rounded-full pill painted by MessageContent,
and it was wrong twice. It respelled a shape the library already had; and the component it was
imitating, Badge, is whitespace-nowrap at a fixed h-5 and could never have rendered the
sentence it was given — a full-round pill around a wrapped sentence is a stadium with dead corners.
AI Elements types its role as the AI SDK's UIMessage["role"], system included, and gives it no
treatment at all.
Repeating the role as a prop on each part is how the two drift — a content that thinks it is a user turn inside a row that says assistant. There is one declaration and one source.
role is the speaker, not the ARIA attribute. The HTML role is deliberately omitted from the
props rather than merged, because two meanings under one name — with the wrong one reaching the DOM
— is worse than an <li> nobody can re-role. If you need a different element, use asChild.
A user turn is laid out with flex-row-reverse rather than justify-end, so the row packs against
the reading end and the avatar goes with it. That is direction-aware where a physical side is not,
and it is why the transcript mirrors correctly under RTL without a second code path.
The parts
MessageContent is the body: a min-width-0 column that wraps, so a long unbroken token cannot push
the row wider than the transcript. On a user turn it caps at 85% and takes a filled rounded bubble;
on an assistant turn it is bare text on the page, which is the shape long answers want. It has no
system branch — see above.
MessageActions is the affordance row under the turn — copy, retry, a source. It is basis-full,
so it breaks onto its own line; that is what the row's flex-wrap is there for. Put a
ButtonGroup inside it rather than loose buttons, so the cluster is
one control group with one label.
MessageAvatar wraps Avatar at size="sm". Give it src for an
image, name for the alt text, or name alone and it derives two initials the way the rest of the
library does.
MessageText is the body of an answer that is still arriving. Hand it the string so far and a
streaming boolean; it re-derives the words and settles each one in as it appears.
An answer arrives a word at a time
A word and not a chunk. A model emits tokens and tokens cut words in half, so animating what
arrives makes half a word fade in and the other half appear a frame later — a glitch, not writing.
MessageText never sees the stream: it takes the text so far and re-derives the words from it, so
there is no queue to keep in step and no arrival event to subscribe to.
<MessageContent>
<MessageText streaming={status === "loading"}>{answer}</MessageText>
</MessageContent>The shape is Streamdown's — the renderer AI Elements uses for an answer and a reasoning block — and four things come from it, each one a bug this would otherwise have:
- Only what is new animates. Here that falls out of the key: each word is keyed by its absolute character offset, so React keeps the elements it already mounted. Keyed by the word, the last one remounts on every frame while its own characters are still arriving, and it flashes instead of settling.
- A batch cascades. A stream does not deliver one word per frame; it delivers twenty at once and then nothing for 200ms. Without a per-word delay the twenty fade in together — which is the flash the animation exists to avoid.
- The cascade has a budget. A stagger applied naively to a fast stream schedules words further and further ahead of the text, so a queue of invisible words builds up behind a stream that has already finished. The step compresses to fit, floored so the batch still cascades rather than collapsing to zero.
- The tree does not change when the stream ends. Same spans, same caret element — the caret is hidden, not unmounted. A component that renders one shape while streaming and another when done re-mounts every word at the finish, and the whole answer arrives twice.
A message that mounts complete does not animate: a transcript loaded from history did not arrive.
The fade is opacity only, 150ms, ease — Streamdown's default of three, the other two being a blur
and a rise. Reduced motion turns it off and nothing else, because the words are already at their
settled opacity with it off.
It renders text, not markdown. Streamdown's larger job is closing incomplete markdown — an
unterminated **bold, a half-arrived code fence — so an answer does not flicker as it completes.
A plain string has no such problem and also no markdown. A source that streams markdown into this
will want that, and it is a dependency rather than a rewrite.
When the answer is markdown
MessageText renders text. The moment a model emits a list, a fence or a **bold, you want
MessageMarkdown — and it is on a subpath:
import { MessageMarkdown } from "@kanzo-tech/ai/markdown";<MessageContent>
<MessageMarkdown streaming={status === "loading"}>{answer}</MessageMarkdown>
</MessageContent>The hard part is not the markdown, it is the incomplete markdown. A stream delivers **bo,
then **bold, then **bold**, and a parser that renders each of those honestly makes the answer
flicker between literal asterisks and emphasis as it completes. Closing them is what
Streamdown is for, and it is a parser's worth of work rather than a
component's — so this wraps it rather than reimplementing it, and the per-word cascade becomes
Streamdown's own animated instead of the copy MessageText carries.
Why a subpath, measured rather than assumed. streamdown bundles to 495 kB minified, 128 kB
brotli on its own — parse5 alone is 123 kB, for rehype-raw's HTML. The whole of
@kanzo-tech/ai's root barrel is budgeted at 20 kB and measures 8.34. A host answering in prose
must not pay for a markdown parser to write import { Message }, which is the same door
@kanzo-tech/ui/editor stands behind for CodeMirror.
So streamdown is an optional peer: install it yourself, and add its dist to whatever scans
your classes, because MessageMarkdown renders somebody else's element tree.
npm install streamdown@source "../node_modules/streamdown/dist/*.js";Without that second line the markdown comes out structurally correct and completely unspaced —
headings at body size, lists with no indent. @kanzo-tech/ai/styles.css deliberately does not carry
it: emitting Streamdown's utilities for everyone would undo in CSS exactly the door the subpath
holds shut in JavaScript.
Anatomy
MessageList (ul, the turns)
└── Message (li, carries data-role)
├── MessageAvatar
├── MessageContent
│ └── MessageText (optional — an answer still arriving)
│ MessageMarkdown (or this one, when the answer is markdown)
└── MessageActions (basis-full — its own line)MessageList is a plain ul with the list markers off. It is not a scroll container and it does
not follow a stream: that is Conversation, one level up.
API Reference
Message
Renders an li.
| Prop | Type | Default |
|---|---|---|
role | "user" | "assistant" | "system" | "assistant" |
The DOM role attribute is omitted from the props — see the callout above.
MessageAvatar
| Prop | Type | Default |
|---|---|---|
src | string | — |
name | string | — |
size | inherited from Avatar | "sm" |
name names the image and, when there is no image, supplies the fallback's initials. Pass children
and they replace both — an AvatarBadge, an icon, whatever the turn
needs.
MessageText
| Prop | Type | Default |
|---|---|---|
children | string | — |
streaming | boolean | false |
children is a string and not a node: the component splits it, so it has to be text. streaming
draws the caret and nothing else — the tint is per word and runs when a word appears.
MessageMarkdown
On @kanzo-tech/ai/markdown, not the root barrel. Same two props as MessageText, plus everything
Streamdown takes.
| Prop | Type | Default |
|---|---|---|
children | string | — |
streaming | boolean | false |
slot | string | "message-markdown" |
It renders a div of its own, and that element is not decoration: Streamdown drops every prop it
does not consume, so data-slot and data-streaming handed to it reach no attribute at all. They
are written on the element above it instead, which is why slot is declared here when every other
part inherits it.
Other parts
MessageList renders a ul; MessageContent and MessageActions render a div and take that
element's props. All of them carry a data-slot, and every one accepts asChild.
Every part's props are exported as an interface — MessageAvatarProps, MessageMarkdownProps,
MessageProps and MessageTextProps — so a wrapper can take the same props without restating them.
Conversation
The transcript's scroll container. It follows a stream while the reader is at the tail and stops the moment they scroll up — which is the whole component.
Prompt input
The composer, as a real form — so Enter and the button are one path. The submit button is one control wearing three labels.