Skip to main content
Urbicon UI
source

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

ts
if (tokens > 0) { tokens--; allow(); } else reject();
Size
<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

A settled answer renders GFM tables and task lists the same whether the text arrived all at once or streamed chunk by chunk. Set headingLevelStart so message headings stay out of the page outline.

Release checklist

StepOwnerStatus
FreezePlatformDone
Sign-offQAPending

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

Ids in the sources prop activate the matching [n] markers in the text; each becomes a CitationChip whose popover shows the title, snippet, and a policy-checked link. Markers without a matching id stay plain text.

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

The renderer never emits an HTML string, so untrusted model output cannot inject markup. With the default URL policy, scheme-smuggled links become inert text, external images become an alt-text chip, and raw HTML stays literal text.

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:
![remote tracker](https://evil.example/track.gif)

Raw HTML stays literal text, never parsed: <img src=x onerror="alert(1)">`;
</script>

<StreamingMarkdown {content} headingLevelStart={3} />

Custom node renderer

A renderers snippet replaces the built-in renderer for one node type. Use it for syntax highlighting, lightboxes, or router-aware links; here, a custom code-block presentation.

Fenced code flows through your own renderer:

ts custom 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, heading1heading6, 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 props
16 props 1 required
Prop
Type
Default
Description

05 Types

Local type definitions used by this component.

9 types
Name
Kind
Category
Used by
Description

06 Installation

Import

import { StreamingMarkdown } from '@urbicon-ui/blocks';
import type {
  StreamingMarkdownProps,
  MarkdownRenderers,
  MarkdownUrlPolicy,
  CitationSource
} from '@urbicon-ui/blocks';