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.
Both APIs streaming the same content simultaneously. Pending blocks are highlighted while they form.
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.
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.
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.
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)
}
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
}
import {
renderMarkdown,
sanitizeRenderedMarkdown,
} from '@copse/streaming-markdown'
// Output is untrusted HTML — always sanitize at the sink.
el.innerHTML = sanitizeRenderedMarkdown(
renderMarkdown(markdown)
)
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 })
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.
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" />
}
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" />
}
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.
// 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 })
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 })
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 })
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 })