Markdown that renders as it arrives

Two emitters — a pure string→HTML function and an incremental DOM renderer — with pending block states that show partial structure without flashing raw syntax. Built for LLM chat UIs.

Live stream — StreamingMarkdownRenderer
— chars/s — vs LLM — updates
optional extras — fetched on first use: highlight.js not loaded shiki not loaded katex not loaded mermaid not loaded

Interactive comparison

Both APIs streaming the same content simultaneously. Pending blocks are highlighted while they form.

highlighter:
optional extras — fetched on first use: highlight.js not loaded shiki not loaded katex not loaded mermaid not loaded
renderStreamingMarkdown(partial)
renderer.update(partial)

Smoothing chunky token arrival

LLM transports deliver tokens in irregular bursts — a whole word here, a punctuation cluster there. The optional createInputSmoother (behind the @copse/streaming-markdown/smoothing subpath, never in the main bundle) steadies that into a per-character reveal by throttling the string fed to update() on animation frames. It smooths the input, not the output — every released value is a prefix of the full text, so it composes with the pending-block machinery instead of fighting the DOM morph. Both panes below receive the same bursty stream.

raw arrival — update(burst)
smoothed — createInputSmoother

On stream end the smoother flush()es the full target, so completion never carries artificial lag and the smoothed DOM converges on exactly what a single un-smoothed update(fullText) produces. It honours prefers-reduced-motion: reduce by disabling itself (pass-through) — as does turning Smoothing on off above.

API

Two emitters for the same content stream — pick the one that fits your rendering pipeline. The sanitizer, syntax highlighter, mermaid & custom fenced blocks, and link routing are all pluggable; see the extending guide.

String emitter — rerenders on each chunk, minimal setup
import { renderStreamingMarkdown } from '@copse/streaming-markdown'

// Call with the full partial string on each token
for await (const chunk of stream) {
  accumulated += chunk
  el.innerHTML = renderStreamingMarkdown(accumulated)
}
DOM emitter — incremental updates, avoids full innerHTML churn
import { StreamingMarkdownRenderer } from '@copse/streaming-markdown'

const renderer = new StreamingMarkdownRenderer(el)

for await (const chunk of stream) {
  accumulated += chunk
  renderer.update(accumulated) // incremental DOM patch
}
At-rest rendering — sanitized HTML from complete markdown
import {
  renderMarkdown,
  sanitizeRenderedMarkdown,
} from '@copse/streaming-markdown'

// Output is untrusted HTML — always sanitize at the sink.
el.innerHTML = sanitizeRenderedMarkdown(
  renderMarkdown(markdown)
)
Sanitizer backend — swap in DOMPurify for Node/jsdom
import { renderMarkdown } from '@copse/streaming-markdown'
import { dompurifyBackend }
  from '@copse/streaming-markdown/sanitizers/dompurify'

// Name the backend in the per-render config.
// Default is the native Sanitizer API (zero-dependency).
renderMarkdown(md, { sanitizerBackend: dompurifyBackend })

First-party React components

React bindings ship as the @copse/streaming-markdown/react subpath — React is an optional peer dependency, so it stays out of your bundle until you import it. Two components mirror the core's two rendering paths: each owns its node and routes every DOM write through the sanitizer internally. Full guide — SSR, the config prop, and a react-markdown migration — in the React guide.

At rest — a complete document, SSR-safe
import { Markdown } from '@copse/streaming-markdown/react'

function Message({ content }) {
  // Owns the node; every write goes through the sanitizer.
  return <Markdown markdown={content} className="streaming-markdown" />
}
Streaming — incremental DOM per token
import { StreamingMarkdown } from '@copse/streaming-markdown/react'

function Chat({ text }) {
  // Feed the growing text; it patches only the settled
  // tail per token — not a re-render of the whole document.
  return <StreamingMarkdown markdown={text} className="streaming-markdown" />
}

Heavy dependencies are optional — and lazy

The core bundle carries no highlight.js, no mermaid, no KaTeX, and no Shiki, so first paint stays fast. Code fences render immediately as escaped plain text with their final hljs lang-* class; ```mermaid fences and $$…$$ / ```math blocks render as inert source. Each library is fetched only when the stream first needs it — highlight.js grammars as a ~71 KB code-split chunk; mermaid, KaTeX, and Shiki as optional peer dependencies the host installs (this page maps each to a CDN). When one arrives, a re-render upgrades what's already on screen in place — no layout shift, because the element classes never change. That's exactly what this page does: watch the four status chips above flip as the demo streams its first code fence, diagram, and equation — or when you switch the highlighter to Shiki. See the lazy-loading guide for the design.

Lazy syntax highlighting — grammars stay out of your main bundle
// e.g. on the first fenced block, or at idle.
// Until then, fences are safe escaped text.
const { loadHighlightjs } = await import(
  '@copse/streaming-markdown/highlighters/highlightjs'
)
const highlighter = await loadHighlightjs()

// re-render with the backend in config: plain
// fences upgrade to token spans
rerender({ codeHighlighter: highlighter })
Lazy diagrams — mermaid is an optional peer, never bundled
import { hydratePendingDiagrams } from '@copse/streaming-markdown'

const { loadMermaid } = await import(
  '@copse/streaming-markdown/diagrams/mermaid'
)
const renderer = await loadMermaid() // mermaid loads on first render

// pending <pre class="mermaid"> scaffolding → SVG
await hydratePendingDiagrams(messageEl, { renderer })
Lazy math — KaTeX is an optional peer, never bundled
import { hydratePendingMath } from '@copse/streaming-markdown'

const { loadKatex } = await import(
  '@copse/streaming-markdown/math/katex'
)
const renderer = await loadKatex()

// pending $$…$$ / \\[…\\] scaffolding → rendered KaTeX
// (prose $…$ needs { mathSyntax: true } at render time)
await hydratePendingMath(messageEl, { renderer })
Pluggable highlighter — swap highlight.js for Shiki
const { loadShiki, shikiThemeCss } = await import(
  '@copse/streaming-markdown/highlighters/shiki'
)
const highlighter = await loadShiki()

// inject the theme palette once (class-based tokens)
injectCss(shikiThemeCss())
// re-render with Shiki in config; fences upgrade in place
rerender({ codeHighlighter: highlighter })