StreamingMarkdownexperimental
A markdown renderer for streaming LLM output. It parses a growing string incrementally, caches settled blocks, and applies a strict URL policy by default. Because it renders to real components instead of an HTML string, untrusted output cannot inject markup.
Playground
Rate limiting with a token bucket
A token bucket lets short bursts through while capping the long-run rate. The bucket refills at a steady pace, and each request spends one token.
Refill tokens on a timer, up to a maximum
Allow a request when a token is free, otherwise reject it
if (tokens > 0) { tokens--; allow(); } else reject(); <script lang="ts">
import { StreamingMarkdown } from '@urbicon-ui/blocks';
let content = $state(`## Rate limiting with a token bucket
A **token bucket** lets short bursts through while capping the long-run rate.
The bucket refills at a steady pace, and each request spends one token.
- Refill tokens on a timer, up to a maximum
- Allow a request when a token is free, otherwise reject it
\`\`\`ts
if (tokens > 0) { tokens--; allow(); } else reject();
\`\`\`
`);
</script>
<StreamingMarkdown
{content}
headingLevelStart={3}
streaming
/>01 Examples
Static markdown — tables and task lists
Release checklist
| Step | Owner | Status |
|---|---|---|
| Freeze | Platform | Done |
| Sign-off | QA | Pending |
Remaining work:
-
Bump the version
-
Regenerate the docs
-
Publish the tag
<script lang="ts">
import { StreamingMarkdown } from '@urbicon-ui/blocks';
// A settled answer (streaming={false}): GFM tables and task lists render the
// same whether the text arrived all at once or chunk by chunk.
const content = `## Release checklist
| Step | Owner | Status |
| -------- | -------- | ------- |
| Freeze | Platform | Done |
| Sign-off | QA | Pending |
Remaining work:
- [x] Bump the version
- [x] Regenerate the docs
- [ ] Publish the tag
`;
</script>
<StreamingMarkdown {content} headingLevelStart={3} />
Citations from sources
Transformers replaced recurrence with self-attention , and later work mapped how quality scales with model and dataset size . A bare marker like [3] with no matching source stays plain text.
<script lang="ts">
import { StreamingMarkdown, type CitationSource } from '@urbicon-ui/blocks';
// Ids activate the matching `[id]` markers in the text; the markers render as
// CitationChip (1-based, in array order). `[3]` below has no source, so it
// stays plain text — prose like "step [3]" is never mangled.
const sources: CitationSource[] = [
{
id: '1',
title: 'Attention Is All You Need',
url: 'https://arxiv.org/abs/1706.03762',
snippet: 'The Transformer, based solely on attention mechanisms.'
},
{
id: '2',
title: 'Scaling Laws for Neural Language Models',
url: 'https://arxiv.org/abs/2001.08361',
snippet: 'Quality improves smoothly with model size, dataset size, and compute.'
}
];
const content = `Transformers replaced recurrence with self-attention [1], and later
work mapped how quality scales with model and dataset size [2]. A bare marker
like [3] with no matching source stays plain text.`;
</script>
<StreamingMarkdown {content} {sources} headingLevelStart={3} />
Untrusted input stays inert
A normal link renders as a real link.
A scheme-smuggled link becomes inert text with a dotted underline — the URL never reaches the DOM.
External images are blocked and shown as an alt-text chip: remote tracker
Raw HTML stays literal text, never parsed: <img src=x onerror="alert(1)">
<script lang="ts">
import { StreamingMarkdown } from '@urbicon-ui/blocks';
// Untrusted model output. The strict-by-default URL policy neutralizes every
// hostile shape without any extra configuration.
const content = `A [normal link](https://ui.urbicon.de) renders as a real link.
A [scheme-smuggled link](javascript:alert(1)) becomes inert text with a dotted
underline — the URL never reaches the DOM.
External images are blocked and shown as an alt-text chip:

Raw HTML stays literal text, never parsed: <img src=x onerror="alert(1)">`;
</script>
<StreamingMarkdown {content} headingLevelStart={3} />
Custom node renderer
Fenced code flows through your own renderer:
export const answer = 42;
<script lang="ts">
import { StreamingMarkdown } from '@urbicon-ui/blocks';
const content = `Fenced code flows through your own renderer:
\`\`\`ts
export const answer = 42;
\`\`\`
`;
</script>
<!--
A `renderers` snippet fully replaces the built-in renderer for one node type —
the hook point for syntax highlighting, lightboxes, or router-aware links,
without pulling any of those into the core. Here: a custom code presentation.
-->
{#snippet codeBlock({ code, lang }: { code: string; lang?: string; open?: boolean })}
<div class="border-primary/40 rounded-contain mt-4 overflow-hidden border first:mt-0">
<div class="bg-primary-subtle flex items-center justify-between px-3 py-1.5">
<span class="text-primary font-mono text-xs">{lang ?? 'code'}</span>
<span class="text-text-tertiary text-xs">custom renderer</span>
</div>
<pre class="text-text-primary overflow-x-auto px-3 py-2.5 font-mono text-sm">{code}</pre>
</div>
{/snippet}
<StreamingMarkdown {content} renderers={{ codeBlock }} headingLevelStart={3} />
02 Customization
Every element maps to a named slot (paragraph, heading1–heading6, inlineCode, codeBlock, table, …). Restyle any of
them via slotClasses, or register a reusable look as a preset on BlocksProvider. Use renderers only when you
need to replace a whole node type (highlighting, custom links); use slotClasses for pure styling.
The urlPolicy is strict by default. Widen it narrowly: allow a specific image CDN
via allowedImagePrefixes rather than a broad prefix. Keep the policy object referentially
stable; a new reference re-parses the whole content.
03 Accessibility
Heading hierarchy
Markdown # maps to the DOM level set by headingLevelStart (deeper levels shift along, clamped
at h6). In a chat, set it to 3 so a message's own headings slot beneath the page <h1> instead of competing with it. Visual sizing
keeps following the author's level independently.
Streaming cursor
The pulsing cursor shown while streaming is true is decorative and carries aria-hidden="true", so screen readers announce only
the text. Its pulse is gated on motion-safe:, so it holds still under prefers-reduced-motion.
Scrollable tables
Wide tables scroll inside a focusable region (tabindex="0") labelled by tableRegionLabel, so keyboard users
can reach and scroll the overflow (WCAG 2.1.1). Horizontal scroll stays inside the block —
never the page.
Blocked links and images
A policy-blocked link renders as inert text with a dotted underline as the "this was a link" cue; a blocked image becomes an alt-text chip. The blocked state is visible, so the reader can tell something was withheld.
04 API Reference
16 propsProp | Type | Default | Description | |
|---|---|---|---|---|
content required | string | — | Markdown source. Append-only growth streams incrementally; any other change re-parses. | |
autolink | boolean | false | Recognize bare https://… autolinks (GFM-style) | |
class | string | — | Custom CSS class | |
headingLevelStart | 1234 +2 more | 1 | DOM heading level that markdown # maps to (deeper levels shift along,
clamped at h6). Visual sizing keeps following the author's level. Set to
3 in a chat so message headings stay out of the page outline. | |
linkTarget | _blank_self | '_blank' | Target for rendered links; rel="noopener noreferrer" is added for _blank | |
preset | string | — | Apply a named preset registered via <BlocksProvider presets={{ StreamingMarkdown: {...} }}>.
Prefer this over class overrides for reusable custom looks. | |
renderers | MarkdownRenderers | — | Per-node-type snippet overrides (code highlighting, router links, lightboxes, …) | |
size | smmd | 'md' | Type scale: md inherits the surrounding font size, sm is compact | |
slotClasses | Partial<Record<StreamingMarkdownSlots, string>> | — | Per-slot class overrides. Slots: base | paragraph | heading1–6 | inlineCode | link | linkBlocked | image | imageBlocked | listUnordered | listOrdered | listItem | taskItem | taskCheckbox | blockquote | codeBlock | tableWrapper | table | tableRow | tableHeadCell | tableCell | hr | cursor | |
sources | CitationSource[] | — | Citation sources. Ids activate [id] / 【id】 markers in the content,
rendering them as CitationChip (1-based index follows array order).
Markers without a matching id stay plain text. | |
streaming | boolean | false | Shows a pulsing cursor after the last block while the answer streams | |
tableRegionLabel | string | 'Table' | aria-label for the focusable scrollable region around tables | |
unstyled | boolean | — | Remove default styles | |
urlPolicy | MarkdownUrlPolicy | — | URL policy for links and images. Strict by default: links limited to http/https/mailto/tel + relative, every external image blocked. Keep the object referentially stable — a new reference re-parses the content. | |
...HTMLAttributes<HTMLDivElement> inherited | HTMLAttributes | — | HTML attributes (excluding: 'children') | |
...StreamingMarkdownVariants variant | VariantProps | — | Styling variants from StreamingMarkdownVariants |
05 Types
Local type definitions used by this component.
Name | Kind | Category | Used by | Description | |
|---|---|---|---|---|---|
StreamingMarkdownProps | interface | props | 0 | Props interface for StreamingMarkdown component | |
CitationSource | interface | helper | 1 | A cited source surfaced behind a [id] citation marker. id keys the
source to its marker; title is always shown, url / snippet are optional
and only render when present (and, for url, when the URL policy allows it). | |
MarkdownUrlPolicy | interface | helper | 1 | — | |
MarkdownRenderers | interface | helper | 1 | Snippet overrides per markdown node type. Each snippet fully replaces the built-in renderer for that node — the hook point for syntax highlighting, lightboxes, router-aware links, or custom citation chips, without pulling any of those dependencies into the core. | |
StreamingMarkdownSlots | type | variant | 0 | Slot names derived from the tv() config — single source of truth for slotClasses. | |
StreamingMarkdownVariants | type | variant | 1 | — | |
InlineNode | type | helper | 0 | Shared contracts for the streaming markdown engine.
The engine is a zero-dependency CommonMark/GFM *subset* parser that renders
to a component tree, never to an HTML string. Raw HTML in the source is
treated as plain text.
Streaming model: createIncrementalParser() accepts append-only chunks.
Settled blocks keep object identity across appends (Svelte's keyed {#each}
then skips re-rendering them); only the unsettled tail is re-parsed per
append, after running repairMarkdownTail over it. | |
SlotNames | type | helper | 0 | Extracts the slot-name union from a slotted tv() config function — the
companion to VariantProps. The slot-mode overload returns
(props?) => { [K in keyof S]: SlotFn }, so keyof ReturnType<T> is exactly
the set of slot names a component declares in tv({ slots: … }).
Use it to type a component's slotClasses prop from the single source of
truth (its *.variants.ts) instead of hand-maintaining a parallel union
that silently drifts when a slot is added or renamed: | |
VariantProps | type | helper | 1 | — |
06 Installation
Import
import { StreamingMarkdown } from '@urbicon-ui/blocks';
import type {
StreamingMarkdownProps,
MarkdownRenderers,
MarkdownUrlPolicy,
CitationSource
} from '@urbicon-ui/blocks';