Streaming Text
An incremental Markdown renderer for LLM output — frozen blocks, speculative closing of half-written markup, an inline cursor and one screen-reader announcement per answer.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/streaming-text.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "StreamingText" component that renders an
LLM reply while it is still arriving. No Markdown library: ship a small parser
so the incremental strategy can be built into it.
Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>
minus children.
- text: string — everything received so far; the caller appends to it.
- status?: "streaming" | "complete" | "interrupted" (default "streaming").
- markdown?: boolean (default true) — false renders plain text, whitespace
preserved, markers literal.
- cursor?: boolean (default true) — blinking caret while text is arriving.
- smooth?: boolean (default false) — even out bursty arrival.
- announce?: "complete" | "blocks" | "off" (default "complete").
- incompleteLabel?: string (default "Stopped — response incomplete").
Behavior — incremental rendering (the whole point)
- Never re-parse the finished part of the answer. Split `text` into top-level
blocks at the three boundaries later text can never undo: a blank line
outside a fence, a closing fence, and an ATX heading line. Every block except
the last is frozen.
- Render each block through a React.memo'd <Block source open cursor /> keyed by
index. A frozen block's props never change, so React skips it: no re-parse, no
re-render, and its DOM nodes (with any text selection in them) survive.
- Do NOT support setext headings or reference link definitions: both let later
text rewrite an already-frozen block, which would break the freeze invariant.
- Emit text in prefix-stable chunks (~64 chars, cut at the last space; one node
per line inside code) instead of one growing string. React updates a text node
with node.nodeValue = next, and the DOM replace-data algorithm collapses every
live range inside a rewritten node — chunking means an append only rewrites
the LAST chunk, so a reader selecting a sentence keeps it.
- Never use dangerouslySetInnerHTML. React 19 re-applies it by object identity,
so a fresh {__html} object each frame wipes the subtree (and the selection)
every token.
Behavior — half-written Markdown
- In the growing tail only, close dangling markers speculatively instead of
falling back to literals: `**bo` renders bold, an unterminated `` ` `` renders
code, `~~x` renders struck through, a fence with no closer renders a code
block, a pipe row before its delimiter row renders as a header-only table.
- A dangling `[label](https://exa` renders the label as plain text — never a
link-styled span with nowhere to go — and becomes a real <a> when `)` lands.
- Markers that Markdown itself would keep literal stay literal: `2 * 3` (marker
followed by whitespace can never open emphasis), snake_case underscores.
- A partial fence marker at the very end (`` ` ``, ``` `` ```) and a bare opener
with no body yet render as nothing, so the last line does not flicker.
- Speculation is on for status "streaming" and "interrupted" and OFF for
"complete": a finished answer renders exactly what strict Markdown says.
- The speculative branch for tables also requires a leading `|`, so prose that
merely contains a pipe never turns into a table.
Behavior — cursor, interruption, smoothing
- Thread the cursor into the deepest last inline slot: inside the last list
item, last table cell, the code body. Never append it as a block sibling.
- Render it as an EMPTY inline <span> with border-right, not an inline-block.
An atomic inline is a UAX#14 CB, so the line breaker may break right before
it and drop the cursor onto a line of its own when the last line happens to
fill the column. An empty inline contributes no character and cannot.
- status="interrupted" keeps every character received and adds one muted line
("Stopped — response incomplete") plus the same words in the announcement.
Nothing is ever cleared.
- smooth: a rAF loop reveals characters with an exponential ramp (backlog *
dt / 120ms, at least 1 char per frame) so the displayed text is always a
prefix of `text` and always converges — no character can be dropped. Cancel
the frame on unmount and skip smoothing entirely under reduced motion.
Behavior — screen readers
- The CONTENT wrapper carries aria-busy while text is arriving — not the root:
assistive tech may hold a live region back while an ancestor is busy, which
would swallow the per-block announcements.
- A separate sr-only <div role="status" aria-live="polite" aria-atomic="false">,
outside that busy subtree, stays EMPTY during the stream and receives the
finished answer as one added node when it ends (announce="complete"), or one
node per finished block (announce="blocks"). Updating a live region per token
makes a screen reader restart mid-word 500 times; this way it speaks once.
- Derive the announcement from props — no effect, no timer, nothing to leak.
Rendering & styling
- Semantic tokens only: text-foreground, text-muted-foreground, bg-muted,
bg-muted/50, border, border-border, text-primary for links, bg-current for
the cursor and the stop glyph. No hex, no rgb(), no oklch().
- Supported subset: ATX headings, paragraphs, fenced code (with a language
label), blockquotes, ordered/unordered lists with one nesting level, GFM
tables, thematic breaks, inline code / bold / italic / bold-italic /
strikethrough / links / escapes. Images render as their alt text so a late
remote image cannot reflow a live answer.
- A single newline inside a paragraph is a hard line break (GFM comment style),
not a soft space, and emphasis does not span lines — both keep every line
independently parseable, which is what makes the tail cheap to re-render.
- Long code lines scroll inside their card (overflow-x-auto) and tables scroll
in a wrapper; the root uses wrap-anywhere so an unbreakable URL cannot widen
the page.
- href allow-list: http(s) and mailto only; every other scheme renders as inert
text. External links get rel="noopener noreferrer".
- The cursor keyframes ship in a React 19 hoisted <style href precedence>, and
the blink is motion-reduce:[animation:none] — the cursor stays solid, visible
and functional under prefers-reduced-motion.
Customization levers
- Chunk size (64): smaller keeps more of a selection alive while the tail is
written, larger means fewer DOM nodes. 32–128 is the useful range.
- Smoothing time constant (120ms): raise it for a calmer, more "typed" feel,
lower it to hug the real arrival rate. Set smooth={false} for zero timers.
- Announcement policy: "off" when the surrounding message list already owns a
live region (avoids the sr-only copy duplicating the visible text in browse
mode); "blocks" when answers are long and users want them read as they land.
- Block styling: every block renderer is one JSX branch — swap the code card
for your own syntax highlighter, drop the language label, restyle tables.
- Cursor look: change border-r-2 to a wider bar or a background block, or set
cursor={false} and let a sibling component own the "generating" affordance.
- Density: the root's text-sm / leading-relaxed and each block's my-* are the
only spacing knobs; override them through className.
- Line-break policy: drop the <br/> between paragraph lines if your model
hard-wraps at 80 columns and you want CommonMark's soft-break behaviour.Concepts
- Freeze boundary — a blank line, a closing fence and a heading are the three places later tokens can never reach back through; everything above the last one is final, so it is parsed once and then skipped forever.
- Speculative close — inside the growing tail an unmatched
**,`,~~, fence or table header is rendered as if it had closed at the end of the text, so the reader sees styled prose instead of a flash of raw markup; strict Markdown resumes the moment the stream reportscomplete. - Prefix-stable chunks — text is emitted as ~64-character nodes cut at word boundaries; because React rewrites a text node wholesale (and the DOM collapses every live range inside it), only the last chunk is disturbed by an append and a reader's selection survives.
- Inline caret — an empty inline span with a right border rides the last character; an
inline-blockcaret would be an atomic inline the line breaker is allowed to move to a line of its own. - Quiet live region — the visible text is marked
aria-busywhile it grows and therole="status"region stays empty, so the answer is announced once at the end instead of interrupting the reader on every token. - Partial answers are answers — an interrupted stream keeps every character already received and gains one muted "incomplete" line; nothing is cleared, because a stopped reply is still the only reply the user has.
Chat Window
A chat shell in three presentations — inline, docked and modal — where switching never re-creates the panel, so the draft, caret and reading position survive.
Message List
An AI transcript whose scroll behaves — glued to the newest token while streaming, released the instant you scroll up, anchored when older pages land, and windowed so a 1000-turn thread stays 12 nodes wide.