Streaming Markdown
A progressive Markdown renderer for LLM streams that paints only what can no longer change — the ambiguous tail waits in a buffer instead of flashing raw markup.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/streaming-markdown.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "StreamingMarkdown" component that renders
Markdown while it is still being written. No Markdown library: ship a small
parser, because the streaming strategy has to live inside it.
The strategy is commit-on-close. Compute the longest prefix of the received
string whose meaning can no longer change, render that prefix with a STRICT
Markdown parser, and hold the rest in a buffer until it closes. A character is
painted at most once, and never repainted differently.
Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement> minus
children and content.
- content: string — everything received so far; the caller appends to it.
- isStreaming?: boolean (default true) — false flushes the buffer, drops the
caret and fires onDone.
- onDone?: (content: string) => void — fired exactly once per stream.
- granularity?: "inline" | "line" | "block" (default "inline").
- caret?: boolean (default true).
- maxHold?: number (default 400) — ceiling on the withheld buffer.
- announce?: boolean (default true) — one screen-reader announcement per answer.
- Also export the scanner: splitCommit(content, granularity, maxHold) returns
{ shown, held, flushed }. A debug panel that prints `held` explains the whole
component in one glance, and it makes the scanner unit-testable as pure string
maths, with no React in the loop.
Behavior — the commit scanner (the whole point)
- Walk the received text line by line, tracking two things: whether a fence is
open, and where the last provably finished block ended. The three boundaries
later tokens can never reach back through are a blank line outside a fence, a
closing fence, and an ATX heading line.
- Everything after the last newline is the line still being written. Inside it,
scan the inline grammar and stop at the first marker that is still open: an
unterminated code span, a `[label](half-a-url`, a `**` with no partner, a `~~`
with no partner, a trailing backslash whose escapee has not arrived, a
trailing `!` that may still become an image. Commit up to that index, hold the
rest.
- Markers that Markdown itself keeps literal are NOT openers and must never
stall the stream: a marker followed by whitespace (`2 * 3`), `_` inside a word
(snake_case), a `[bracket]` whose `]` has already gone by without a `(`.
- A marker that could still close later (an `_` whose only candidate closer sits
inside a word) stays held. It is released at the latest when the line ends: a
newline settles every inline construct, because emphasis never spans lines.
- Block markers with no content yet are pure markup and are withheld whole: a
bare `#`, `-`, `1.`, `>`, a one-or-two-character backtick run, a fence opener
line whose body has not started. A bare digit counts too — the next token is
very often the `.` that turns it into a list marker — and so does indentation
on its own, which may be the indent of a nested item.
- The commit point must be MONOTONIC: for every prefix of the stream the newly
committed text starts with the previously committed text. Property-test that
over a corpus (feed every prefix, assert shown[k+1].startsWith(shown[k])), and
fuzz it with markup soup — the three rules it will catch are easy to miss:
(a) a construct whose closer is the last character received is not settled,
because one more `*` or backtick extends the run; (b) for `_` the closer's
meaning also depends on the character after it (`_a_` vs `_a_b`); (c) a
backtick run only pairs when the WHOLE run finds a partner of the same length,
otherwise a backtick arriving later re-pairs the span around different text.
- Inside an open fence there is no inline syntax, so code may be painted
character by character — except a trailing run of fence characters alone on a
line, which may become the closing fence and would otherwise land in the code
body for a frame.
- granularity trades latency for calm: "inline" cuts at the first open marker,
"line" commits only newline-terminated lines, "block" commits only finished
blocks (a paragraph lands with its blank line, a code card lands with its
closing fence). The prop must not change what is eventually rendered — only
when.
- Starvation guard: if more than maxHold characters are stuck behind an
unresolved marker (a model that opens `**` and never closes it, a fence that
runs for pages), paint the raw tail anyway. Showing nothing for a page is a
worse failure than showing two asterisks. Set maxHold={Infinity} to disable.
- The guard is stable, not oscillating: once flushed, the buffer is empty and
the next token re-measures the same overflowing tail.
Behavior — parsing and rendering the committed prefix
- The parser is strict Markdown with zero "is it still growing?" branches. That
entire class of bug lives in the scanner, where it is pure string maths.
- Supported subset: ATX headings, paragraphs, fenced code with a language label,
blockquotes with lazy continuation, ordered/unordered lists with one nesting
level, thematic breaks; inline code, bold, italic, bold-italic, strikethrough,
links, images, escapes. Tables are deliberately out of scope.
- Do NOT support setext headings or reference link definitions: both let later
text rewrite a block that has already been committed, which breaks the one
invariant the component sells.
- A single newline inside a paragraph is a hard line break, not a soft space, so
every line stays independently parseable and the tail stays cheap.
- Split the committed text into top-level blocks at the same three boundaries
and render each through a React.memo'd <Block source caret />. A frozen
block's props never change again, so React skips it: no re-parse, no
re-render, and its DOM nodes — with any text selection inside them — survive
to the end of the answer.
- Never use dangerouslySetInnerHTML. React re-applies it by object identity, so
a fresh {__html} each frame wipes the subtree and the selection every token.
- href allow-list: http(s) and mailto only; every other scheme renders as inert
text. External links get rel="noopener noreferrer". Images render as their alt
text so a late remote image cannot reflow a live answer.
Behavior — lifecycle, caret, screen readers
- Thread the caret into the deepest last inline slot: inside the last list item,
inside the code body, after the last committed character. Render it as an
EMPTY inline <span> with border-right, never an inline-block — an atomic
inline is a line-break opportunity, so the line breaker may drop the caret
onto a line of its own when the last line happens to fill the column.
- If the commit point lands on a thematic break (no inline slot) give the caret
a line of its own, so it never blinks out between blocks.
- onDone is a one-shot lock keyed on stream identity: track the previous
content, and treat a REPLACED (not extended) string as a new stream that may
fire again. Mounting with isStreaming={false} starts the lock already claimed,
so re-rendering a finished message never re-announces it.
- isStreaming={false} keeps every character received — a stopped answer is still
the only answer the user has — and simply stops withholding.
- The CONTENT wrapper carries aria-busy while the stream is open, not the root:
assistive tech may hold a live region back while an ancestor is busy.
- A separate sr-only <div role="status" aria-live="polite"> outside that subtree
stays EMPTY for the whole stream and receives the finished answer, markers
stripped, as one node at the end. A live region updated per token restarts a
screen reader mid-word hundreds of times.
- No timers, no rAF, no observers: everything is derived from props, so there is
nothing to clean up and nothing to leak. The only effect is the one-shot done
signal.
Rendering & styling
- Semantic tokens only: text-foreground, text-muted-foreground, bg-muted,
bg-muted/50, border, border-border, text-primary and ring for links,
bg-current for the caret. No hex, no rgb(), no oklch().
- cn() merges the consumer className into the root; expose data-granularity,
data-streaming and data-held on the root so a debug overlay or a test can read
the buffer size without re-implementing the scanner.
- Long code lines scroll inside their card (overflow-x-auto) rather than
widening the answer; the root uses wrap-anywhere so an unbreakable URL cannot
push the layout out.
- The caret keyframes ship in a hoisted <style href precedence> element and the
blink is motion-reduce:[animation:none] — under prefers-reduced-motion the
caret stays solid, visible and still marks the commit point.
- Links get a focus-visible ring; the caret is aria-hidden.
Customization levers
- granularity is the main dial: "inline" for a chat answer that should feel
live, "block" for a report or an agent log where a calm, sectioned reveal
reads better than a growing sentence.
- maxHold (400): lower it if your model emits long unclosed constructs and you
would rather see raw markers than a stalled panel; Infinity if you never want
raw markup, at the risk of a long pause.
- Supported subset: every block renderer is one JSX branch — swap the code card
for your own highlighter, drop the language label, add a table branch (and a
matching "delimiter row has not arrived" rule in the scanner if you do).
- Caret look: change border-r-2 to a wider bar or a filled block, or set
caret={false} and let a sibling 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.
- Announcement: set announce={false} when the surrounding message list already
owns a live region, so the sr-only copy does not duplicate the visible text.
- 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
- Commit point — the frontier between "already decided" and "still ambiguous"; it only ever moves forward, which is what lets the renderer promise that a painted character is never repainted differently.
- Held tail — the withheld suffix lives in a buffer, not on screen: a
**with no partner, a fence opener with no body, a link whose URL is half typed. It lands whole the instant its closing token arrives, so the reader sees styled prose appear, never markup. - Commit granularity — the same stream, three cadences: cut at the first open marker (live), at the last newline (line by line), or at the last finished block (calm, sectioned). The dial changes when things appear, never what.
- Strict parser — because the parser only ever sees settled text it has no "is this still growing?" branches at all; all streaming logic is isolated in one pure string function that can be tested without React.
- Starvation guard — a marker that never closes would otherwise hold the answer hostage, so past a character ceiling the raw tail is painted anyway: two visible asterisks beat a blank panel.
- One-shot done — completion is a lock keyed on stream identity, so
onDonefires once per answer; re-rendering a finished message, or a parent that recreates the callback each frame, cannot fire it twice.
Message Skeleton
A chat-shaped loading placeholder — seeded conversation rhythm, alternating bubbles or document rows, a sweep phase-shifted through the thread, and one late screen-reader announcement.
Streaming Table
A table that renders while its rows are still arriving — the header locks the column geometry on frame one, cells still in flight shimmer in place, and the footer count settles into a total.