Stream Markdown
A Markdown renderer built for streaming output — new text enters with the StreamElement family's reveal and the Tuff caret rides the write head — with per-language fenced-block renderers.
Usage
Stream Markdown
Loading demo...
Best Practices
- Do not turn
sanitizeoff. Model output is untrusted content and this is where it becomes DOM. - Keep
streamingtrue for the duration and set it false when generation ends, or the caret stays at the tail forever. - Handle
fenceClosed === falsein custom renderers with a loading state rather than parsing a partial block. - Key
renderersby the lowercase language that opens the fence (```mermaid→mermaid). - Use
TxMarkdownViewfor static Markdown; it does not need the block-splitting and cursor machinery.
API Reference
Props
| Name | Type | Default | Description |
|---|---|---|---|
content | string | — | The Markdown source. Required. |
streaming | boolean | false | Whether output is still arriving; drives the caret, the reveal and deferred tail-fence rendering. |
reveal | 'aurora' | 'hue' | 'blur' | 'languid' | 'none' | 'aurora' | How newly streamed text enters, with TxStreamText's presets. A streamed chunk runs across words and stays inline, so languid keeps its slow blur but not its rise. |
caret | boolean | true | Shows the Tuff caret at the write head while streaming. Switched off, the caret goes at once; a stream that ends retracts it. |
sanitize | boolean | true | Whether to sanitize rendered HTML through dompurify. |
theme | 'light' | 'dark' | 'auto' | 'auto' | Colour theme. |
renderers | Record<string, StreamMarkdownBlockRenderer> | — | Per-language fenced-block renderers, e.g. { mermaid: TxMermaidBlock }. |
Events
TxStreamMarkdown emits no component events.
Slots
TxStreamMarkdown exposes no slots. Register a component through renderers to take over a particular kind of block.
Overview
- The document is split into blocks and rendered per block, so appending text does not reflow the whole page — the property that makes this usable while streaming.
- While
streamingis true an unclosed tail fence has its rendering deferred: a half-written code block does not flash in a wrong form first, it waits for the closing fence. - The caret is the Tuff caret, the same as
TxStreamText's. It sits after the last character of a paragraph, heading or quote, and on its own line below a list, table or fence. It is one element placed with thetranslateproperty: it takes no room, so nothing reflows around it, and it never remounts as the write head moves between elements. It is re-placed once per frame, and on resize, while the stream is live, and it retracts whenstreamingturns false. (translate, nottransform: the enter and leavescalethen grows it in place instead of flying it in from the corner.) - New text enters with the family's reveal. Newly streamed characters take the
revealpreset — by defaultaurora, a fade out of a light blur through the blue → violet → pink sweep onto the ink — and each new block fades in once. A block is re-rendered wholesale on every delta, so the entering characters are re-wrapped after each patch with a negative delay and resume where the patch cut them. - Under reduced motion nothing enters or eases: text shows as it arrives, and the caret rests as a still arc. Every animation and transition is declared only under
prefers-reduced-motion: no-preference. fenceClosedisfalseonly for the still-growing tail fence of a streaming document. Indented code has no fence and also reportsfalse; any non-tail block is treated as complete.sanitizeis on by default and loads dompurify through a dynamicimport(). Keep it on — the content is model output.- If dompurify fails to load, the sanitizer is left null and the component keeps working. Sanitization is therefore best-effort and is not a substitute for a server-side trust boundary.
- Each instance owns its own
Marked(withgfmandbreaks) rather than mutating the global singleton, which would reconfigure every other consumer in the app. renderersare matched by fence language — the first word, lowercased. Unregistered languages fall back to the default code block.- A registered renderer receives
StreamMarkdownBlockContextas props, includingfenceClosed, so it can show a placeholder until the block completes. themeacceptslight,darkorauto, withautofollowing the environment.- The bundled GitHub-Markdown stylesheet is a global import, so every rule in it is scoped to
:where(.tx-markdown-view, .tx-stream-md)..markdown-bodyis a very generic class name — before the scope, importing this component restyled any host page that used it for its own prose.:where()adds no specificity, so the scope confines the sheet without changing how its rules compete with each other.