StreamingResponse
A streaming markdown answer for an AI chat — incremental prose, code and tables that never change shape under the reader, with explicit idle/streaming/interrupted/failed states and screen-reader-correct announcements.
pnpm add @tessinaui/uiWhen to use
- An AI answer that arrives a token at a time. Hand it every prefix as it arrives; that is what it is for.
- Markdown in the answer. Prose, lists, tables and fenced code render without shifting what has already arrived.
- A transcript. Put it inside a
ChatBubbleList, so the conversation has one announcer. - An answer that can stop early.
interruptedandfailedare explicit states, told apart by a word and a glyph, and a stopped answer stays readable. A failure says why and offers Retry in place; a stop can offer Regenerate. - A finished answer. It is
state="idle", ordinary prose with nothing live left on it.
When not to use
- For a stop control. PromptInput owns Send and Stop, so a screen never shows two.
- Before the first token. Use the skeleton (
StreamingResponseSkeleton) for an answer that has not started. Once tokens are on the way,state="streaming"reserves the space, and using both announces the same load twice. - Inside your own live region. The announcements are already correct, and a second region says everything twice.
- For a message bubble. The bubble, avatar and timestamp belong to ChatBubble; this renders the answer inside it.
- For static markdown or a code listing. Fenced code is the house CodeBlock; use that directly when nothing streams.
- For math or diagrams. They are not rendered (see Markdown support).
Installation
npx shadcn@latest add https://tessinaui.com/r/streaming-response.jsonIt brings react-markdown and remark-gfm, pinned to the versions the
component was written against. Fenced code renders through the house
CodeBlock, so nothing here paints a second code
surface.
Usage
An AI answer arrives a token at a time, which means every render is of a
prefix of the final text. StreamingResponse renders those prefixes so the
answer never changes shape under the reader: an unterminated code fence is a
code block from its opening line, a half-typed table keeps the column count it
will end with, and content that has already arrived never moves again.
import { StreamingResponse } from "@tessinaui/ui/streaming-response";
export function Answer({ text, isStreaming }: { text: string; isStreaming: boolean }) {
return (
<StreamingResponse
markdown={text}
state={isStreaming ? "streaming" : "idle"}
/>
);
}markdown is controlled and the component owns no timer: hand it whatever has
arrived, as often as it arrives. There is no typewriter reveal, so nothing is
withheld from a reader who already has the text.
Examples
Default
The whole answer — prose, a list, a fence and a table — streamed in and then at rest. Press again to replay it.
States
idle covers both "nothing yet" and "finished": a completed answer is ordinary
prose and needs no state of its own. The two terminal states are told apart by a
word and a glyph, never by colour alone, and a stopped answer stays fully
readable — it is a complete answer that ended early.
Failure and recovery
A failure keeps what arrived and puts an outline error alert under it: the
title, the reason from error, and Retry from onRetry, in place. A stop keeps
the answer at full ink and, with onRegenerate, offers Regenerate beside the
word. Either control hands focus to the answer when it goes away, so a keyboard
reader is never left on the page body.
Three props change how a terminal state is drawn, for a surface that is not an ordinary chat column. Each keeps the glyph and the word, so a state is never told apart by colour alone.
failureVariant="tinted"replaces the alert with a quiet error tint holding the glyph and the reason, with Retry under it. Use it when the surrounding heading already says what failed, so a second title would repeat it.interruptedVariant="line"draws the stopped glyph and word on the body's own line, at the body size, instead of a smaller footer. Use it where every turn already reads as a column of body-size lines.regenerateVariant="button"puts Regenerate on its own line as an outline button, instead of a ghost control beside the stopped word. Use it for an answer that ends the page rather than a turn in a thread.
Code blocks
The fence is a CodeBlock from its opening line. Watch the language badge: it
appears only once the info string is finished, so the block is not
re-highlighted on every keystroke of t → ty → typescript.
Tables
A GFM table needs a header row and a delimiter row before it is a table at all.
The delimiter is completed to the header's column count from the first -, so
the table appears once, with the columns it will keep.
Inside a conversation
Inside a ChatBubble transcript the response reports to the list's single announcer instead of mounting a second live region: one region per transcript, never one per message.
On a coloured surface
Prose, inline code, links, tables and the stopped footer all take the plate's ink; a fenced block keeps its own locked plate, and the failure alert paints its own page-tinted box.
RTL
The prose follows the document's direction. Code stays left-to-right, because code is a sequence rather than a sentence.
Skeleton
For an answer that has not started arriving. Once tokens are on the way,
state="streaming" shows its own reserved shimmer inline instead — using both
would announce the same load twice.
API Reference
StreamingResponse
| Prop | Type | Default | Description |
|---|---|---|---|
markdown | string | "" | The markdown received so far. Any prefix is valid. |
state | "idle" | "streaming" | "interrupted" | "failed" | "idle" | idle is both "nothing yet" and "finished". |
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Body type, on the chat text ladder. |
tone | "default" | "on-color" | inherited | Inherited from a Surface; set it only to override. |
dir | "ltr" | "rtl" | — | Also selects the string table. |
strings | Partial<StreamingResponseStrings> | — | Per-entry overrides. |
error | ReactNode | — | Why generation failed, shown in the failure alert while state="failed". A string is also announced. |
onRetry | () => void | — | Renders Retry inside the failure alert. failed only. |
onRegenerate | () => void | — | Renders Regenerate beside the stopped label. interrupted only. |
failureVariant | "alert" | "tinted" | "alert" | How failed is drawn. alert: the outline alert with a title, the reason and Retry. tinted: a quiet error tint holding the glyph and the reason, with Retry under it, for a surface whose heading already says what failed. |
interruptedVariant | "footer" | "line" | "footer" | How interrupted is drawn. footer: the stopped glyph and word at the meta size under the answer. line: the same glyph and word on the body's own line, at the body size, for a surface whose turns already read as a column of body-size lines. Glyph and word stay in both, so a stop never reads as the failure without colour. |
regenerateVariant | "inline" | "button" | "inline" | Where Regenerate sits on interrupted. inline: a ghost control beside the stopped word. button: an outline button on its own line under the footer, for an answer that ends the page rather than a turn in a thread. |
components | Components | — | react-markdown overrides, merged over the defaults. |
showCursor | boolean | true | The caret while streaming (motion-safe). |
codeTheme | "auto" | "light" | "dark" | "auto" | Forwarded to every fenced CodeBlock. |
StreamingResponseSkeleton
| Prop | Type | Default | Description |
|---|---|---|---|
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Line box, from the component's own ladder. |
lines | number | 3 | Placeholder rows, floored at one. |
label | string | — | The announcement. Silent, and hidden from the tree, when unset. |
tone | "default" | "on-color" | inherited |
Exports
StreamingResponse · StreamingResponseSkeleton · StreamingMarkdown ·
StreamingResponseAnchor · StreamingMarkdownScope ·
STREAMING_RESPONSE_STRINGS · splitMarkdownBlocks · stabilizeMarkdown
Streaming and the streaming-stable contract
Every component in the AI kit is valid under any prefix of its props, and this one is the reference implementation. Four rules, from the house contract (CONVENTIONS §7c):
- No prefix throws. A half-arrived fence, a truncated table or a dangling link renders as text, never as an error.
- Content that has arrived never moves. Completed blocks are memoized and keep their DOM identity, so they are not re-parsed, re-highlighted or reconciled as more arrives.
- Live regions announce transitions, never tokens. See Accessibility.
- The terminal states are explicit, and distinguishable without colour.
Two pure functions do the work, and both are exported for anything that renders streamed markdown itself:
import { splitMarkdownBlocks, stabilizeMarkdown } from "@tessinaui/ui/streaming-response";
const { completed, tail } = splitMarkdownBlocks(source);
// completed.join("") + tail === source, always.
// `completed` only ever grows by appending, so blocks can be keyed by index.
const stable = stabilizeMarkdown(tail);
// Closes an open fence; completes a half-typed table delimiter.
// Idempotent on complete markdown.Markdown support, and its known limits
Headings, paragraphs, bold and italic, links, lists (including task lists),
blockquotes, rules, tables, inline code and fenced code. Raw HTML in the answer
is inert — there is no rehype-raw — which is the right default for model
output.
Deliberate limits, each because closing it would mean inventing content the model has not sent:
- Dangling emphasis renders literally.
**boldshows its asterisks until the closing pair arrives, and so does an ordinary half-typed link. Fences and table delimiters are stabilized because they change layout; a half-typed citation is closed too, because its visible text comes from the source list, not the link. - Reference-style links and footnotes do not resolve across blocks. Completed blocks parse independently. Use inline links in streamed text.
- A setext underline flips the tail once, when the underline arrives: until then it is indistinguishable from an ordinary rule.
- Math and diagrams are not rendered.
Override any element with components, which merges over the defaults. Keep
the map stable — defined outside the render — or every block re-renders.
Citations is built on this seam:
<StreamingResponse markdown={text} components={citationComponents} />An a override that handles only some links can hand the rest back to
StreamingResponseAnchor, the answer's own link; StreamingMarkdownScope
gives any override the size and tone the answer resolved.
Reduced motion
There is nothing to switch off. Chunks appear as they arrive rather than being
typed out, so no text is ever withheld; the streaming caret is the only motion
and it is gated on motion-safe, so under prefers-reduced-motion: reduce it
is a static bar and the content still arrives. The pre-first-token shimmer is
the Skeleton primitive's own gated wave.
Accessibility
Streaming text is never written into a live region. That is the whole design, and it is what makes the finished answer readable:
- The content carries
aria-busywhile tokens arrive, and — because every clause of ARIA'saria-busybatching is aMAY— alsoaria-live="off", so an assistive technology that honours an ancestorrole="log"and ignores the busy flag still cannot speak each token. - A separate, always-mounted
role="status"region announces the start and the end once each ("Generating response", then "Response complete"). It is mounted empty from first paint: a polite region that arrives with its text already inside it is silent. interruptedandfailedannounce once, on their transition; a stringerroris read with the failure. The failure alert is not a live region of its own, so nothing is said twice.- Retry and Regenerate remove themselves when the state moves on, so focus is handed to the answer rather than dropped.
- When the stream ends, nothing live is left on the answer, so a reader reads it as ordinary prose, in order.
- A table that overflows is a focusable, named scroll region, so a keyboard-only reader can reach the columns that scroll.
Every string is overridable through strings, and dir="rtl" selects the
Arabic table.
Assistive-technology matrix
| Technology | Status |
|---|---|
| VoiceOver (macOS, Safari) | Pending a recorded smoke. The ARIA contract it depends on is pinned by tests — the status region is mounted empty from first paint, each transition announces once, and 30 streamed tokens add no announcement — but no screen-reader run is on record yet, which is why this page is beta |
| NVDA | Untested — no Windows host available |
| JAWS | Untested — no Windows host available |
Agent notes
markdownis controlled. There is no internal timer and no typewriter; render what you have.splitMarkdownBlocksandstabilizeMarkdownare pure and dependency-free. Use them if you render streamed markdown yourself rather than re-deriving the rules.- A finished answer is
state="idle", not a fifth state. - Announce nothing yourself: transitions are announced for you.
- On failure pass the reason as
errorand recovery asonRetry; do not replace the component with your own error box. onRetryapplies tofailed,onRegeneratetointerrupted; pass both and the right one shows.
In templates
- AI Chat — each reply, with a failure and its Retry.
- AI Chat Page — the same reply inside a full page.
- AI Answer — one answer with Regenerate after a stop.