AI

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.

Preview in your theme

Loading preview…

"use client"

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

/* -------------------------------------------------------------------------- *
 * Cursor
 *
 * Keyframes ship with the component through a React 19 hoisted <style> — no
 * Tailwind config edits, and duplicate instances dedupe by href.
 *
 * The cursor is an EMPTY inline <span> painted with a right border, NOT an
 * inline-block. An atomic inline (inline-block / replaced element) is a UAX#14
 * CB and the line breaker is allowed to break right before it — which is how a

Installation

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

Prompt

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 reports complete.
  • 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-block caret 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-busy while it grows and the role="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.

On This Page