AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- *
 * Streaming Markdown — commit-on-close
 *
 * A model emits Markdown one token at a time, so at almost every frame the tail
 * of the string is a construct that has not decided what it is yet: `**bo`,
 * "```ts" with no body, `[label](https://exa`. Rendering that tail is a guess.
 *
 * This component refuses to guess. It computes a COMMIT POINT — the longest
 * prefix of `content` whose meaning can no longer change — renders that prefix

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/streaming-markdown.json

Prompt

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 onDone fires once per answer; re-rendering a finished message, or a parent that recreates the callback each frame, cannot fire it twice.

On This Page