Streaming Markdown

Free

Safe Markdown renderer built for token-by-token output. Renders a parsed token model through Angular bindings — no innerHTML and no bypassSecurityTrust, so untrusted content can never become markup. Headings, lists (incl. tasks), tables with alignment, blockquotes and inline emphasis; fenced code delegates to sv-code-block.

</>sv-streaming-markdown markdownstreamingairenderersafe

Complexity tier: Interactive The primitive bar, plus declared async states, a keyboard model, focus restoration and undo for destructive actions.

import { SvStreamingMarkdownComponent } from '@styloviz/streaming-markdown';

Demo & usage

Basic usage

Usage example — open the gallery for the full interactive demo.

An interactive demo for this component lives in the gallery.

Open live demo

More examples

Streaming buffer

The parser is pure, so re-running it on a growing buffer is correct and cheap.

example.htmlAngular
<sv-streaming-markdown [content]="buffer()" [streaming]="!done()" />

Fenced code with line numbers

Fenced blocks render through sv-code-block — highlighting, filename header and copy come for free.

example.htmlAngular
<sv-streaming-markdown [content]="md" [codeLineNumbers]="true" codeMaxHeight="20rem" />

Intercepting links

linkClick fires with the resolved href — useful for routing citations in-app.

example.htmlAngular
<sv-streaming-markdown [content]="md" (linkClick)="router.navigateByUrl($event.href)" />

When to use

  • Rendering an LLM response as it streams in, token by token.
  • Displaying user- or model-authored Markdown where the source is not trusted.
  • Any place a chat message, changelog entry or docs excerpt is stored as Markdown.

Async states

None — this component has nothing to load, so it has no loading, empty or error state to drive.

Inputs

NameTypeDefaultDescription
contentstring''Markdown source. Safe to update on every streamed token.
streamingbooleanfalseRender a blinking caret after the last block, for in-flight responses.
densityMarkdownDensity'md'Text density.
headingOffsetnumber0Added to every heading level, so `#` becomes `<h3>` at an offset of 2. Use it to keep document headings from competing with the surrounding page.
codeLineNumbersbooleanfalseShow line numbers in fenced code blocks.
codeMaxHeightstring'20rem'Max height of a fenced code block, as a CSS length.
ariaLabelstring''Accessible label for the rendered region.
completionMessagestring'Response complete'What a screen reader is told when a streamed response finishes. A LIVE REGION UPDATED PER TOKEN IS UNUSABLE, which is why this component has never had one — but the silence had its own cost: a screen-reader user got no signal at all that generation had finished, and had to poll the region by hand to find out. The answer is not a quieter live region, it is a DIFFERENT event: `aria-busy` while the text is arriving, and one announcement at the moment it stops. Set to `''` to announce nothing at all.

Outputs

EventPayload typeDescription
linkClickMarkdownLinkClickEmits when a rendered link is activated. Call `preventDefault` upstream to intercept.
codeCopiedstringEmits the copied text when a fenced code block is copied.

Customization & theming

  • Zero third-party dependencies: a small rule-based parser emits a token model that the template renders through Angular bindings — no `innerHTML`, no `bypassSecurityTrust*`.
  • Streaming-safe: `parseMarkdown()` is pure, so it can re-run on every token. Unmatched delimiters degrade to literal text and an unterminated fence yields `complete: false` rather than swallowing the document.
  • Supports ATX headings, paragraphs, fenced code, blockquotes, ordered/unordered lists with `- [x]` tasks and indent depth, pipe tables with alignment, and thematic breaks.
  • Inline `**strong**`, `*em*`, `` `code` ``, `~~strike~~` and `[text](url)` nest arbitrarily — the template renders the span tree recursively.
  • Link URLs are scheme-checked at parse time; `javascript:`, `data:` and `vbscript:` are rejected and render as plain text. Accepted links get `rel="noopener noreferrer"`.
  • Fenced code delegates to `sv-code-block`, so highlighting, the filename header and copy-to-clipboard are inherited; tune it with `codeLineNumbers` and `codeMaxHeight`.
  • `headingOffset` demotes every heading; `density` switches between two prose scales.
  • The exported `parseMarkdown()`, `parseInline()` and `safeHref()` helpers are public, so the parser can be reused outside the component.

Best practices

  • Do: Set `headingOffset` inside a chat bubble or card so document headings do not compete with the page hierarchy.
  • Do: Bind `streaming` while a response is in flight — it renders the caret and keeps an unterminated code fence visible.
  • Don't: Do not pre-sanitise or pre-escape the source. The renderer never treats it as markup, and escaping first would show the escapes.
  • Don't: Do not reach for `innerHTML` to add an unsupported construct — extend the parser so the token model stays the only path to the DOM.
  • Tip: Handle `linkClick` to route citations internally instead of opening a new tab.

Accessibility

  • Renders real semantic elements — `<h1>`–`<h6>`, `<ul>`, `<blockquote>`, `<table>` with `<th scope="col">` — so screen readers get the document structure, not a wall of text.
  • Task-list markers are `role="img"` with a Done / Not done label; decorative bullets and the streaming caret are `aria-hidden`.
  • Links carry `rel="noopener noreferrer"` and a visible `focus-visible` ring.
  • Set `ariaLabel` to expose the rendered output as a named `role="region"` when it stands alone.

Feedback on Streaming Markdown

Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.

Loads giscus.app and GitHub — nothing is fetched until you click.