streamdown-vue brings Streamdown-style streaming Markdown to Vue 3 & Nuxt 3. It ships a <StreamMarkdown> component that incrementally renders Markdown as it arrives (token‑by‑token, chunk‑by‑chunk), plus helper utilities to keep partially received text valid.
- Features
- Installation
- Quick Start (Basic SSR + CSR)
- Deep Dive Tutorial (Streaming from an AI / SSE source)
- Props Reference (All
<StreamMarkdown>props) - Component Slots & Overrides
- Built‑in Components & Data Attributes
- Security Model (Link/Image hardening)
- Syntax Highlighting (Shiki), Copy / Download & Extensible Actions
- Mermaid Diagrams
- Math & LaTeX Fixes
- Utilities (
parseBlocks,parseIncompleteMarkdown, LaTeX helpers) - Performance Tips
- Nuxt 3 Usage & SSR Notes
- Recipe Gallery
- FAQ
- Development & Contributing
- GitHub‑flavored Markdown (tables, task lists, strikethrough) via
remark-gfm - KaTeX math (
remark-math+rehype-katex) with extra repairs (matrices, stray$) - Shiki syntax highlighting (light + dark themes) with reactive copy & download buttons and an extensible action bar (add your own buttons globally or per-instance)
- Mermaid diagrams with caching, async render & graceful error recovery
- Incremental rendering + repair of incomplete Markdown tokens while streaming
- Secure allow‑list based hardening of link & image URLs (blocks
javascript:etc.) - Component override layer (swap any tag / embed custom Vue components)
- Data attributes for each semantic element (
data-streamdown="...") for styling/testing - Designed for SSR (Vue / Nuxt) & fast hydration; tree‑shakable, side‑effects minimized
bun add streamdown-vuenpm install streamdown-vue
# pnpm add streamdown-vue
# yarn add streamdown-vueYou must also install peer deps vue (and optionally typescript).
Include KaTeX stylesheet once (if you use math):
import 'katex/dist/katex.min.css';main.ts:
import { createApp } from 'vue';
import App from './App.vue';
import 'katex/dist/katex.min.css';
createApp(App).mount('#app');App.vue:
<template>
<StreamMarkdown class="prose" :content="markdown" />
</template>
<script setup lang="ts">
import { StreamMarkdown } from 'streamdown-vue';
const markdown = `# Hello\n\nSome *markdown* with $e^{i\\pi}+1=0$.`;
</script>SSR (server) minimal snippet:
import { renderToString } from '@vue/server-renderer';
import { createSSRApp, h } from 'vue';
import { StreamMarkdown } from 'streamdown-vue';
const app = createSSRApp({
render: () => h(StreamMarkdown, { content: '# SSR' }),
});
const html = await renderToString(app);When receiving tokens / partial chunks you typically want to:
- Append new text chunk into a buffer.
- Repair the partial Markdown (
parseIncompleteMarkdown). - Split into safe blocks for re-render (
parseBlocks). - Feed the concatenated repaired text to
<StreamMarkdown>.
Composable example (client side):
// useStreamedMarkdown.ts
import { ref } from 'vue';
import { parseBlocks, parseIncompleteMarkdown } from 'streamdown-vue';
export function useStreamedMarkdown() {
const rawBuffer = ref('');
const rendered = ref('');
const blocks = ref<string[]>([]);
const pushChunk = (text: string) => {
rawBuffer.value += text;
// repair incomplete tokens (unclosed **, `, $$, etc.)
const repaired = parseIncompleteMarkdown(rawBuffer.value);
blocks.value = parseBlocks(repaired);
rendered.value = blocks.value.join('');
};
return { rawBuffer, rendered, blocks, pushChunk };
}Using Server-Sent Events (SSE):
const { rendered, pushChunk } = useStreamedMarkdown();
const es = new EventSource('/api/chat');
es.onmessage = (e) => {
pushChunk(e.data);
};
es.onerror = () => es.close();Template:
<StreamMarkdown :content="rendered" />Why repair first? Without repair, a trailing ** or lone ``` will invalidate the final tree and cause flicker or lost highlighting. Repairing keeps intermediate renders stable.
| Prop | Type | Default | Description |
|---|---|---|---|
content |
string |
'' |
The full (or partially streamed) markdown source. |
class / className |
string |
'' |
Optional wrapper classes; both accepted (React-style alias). |
components |
Record<string,Component> |
{} |
Map to override built-ins (e.g. { p: MyP }). |
remarkPlugins |
any[] |
[] |
Extra remark plugins. Supports (plugin) or [plugin, options]. If you supply remark-math yourself, the built‑in one (which disables single‑dollar inline math) is skipped. |
rehypePlugins |
any[] |
[] |
Extra rehype plugins. |
defaultOrigin |
string? |
undefined |
Base URL used to resolve relative links/images before allow‑list checks. |
allowedImagePrefixes |
string[] |
['https://','http://'] |
Allowed (lowercased) URL prefixes for <img>. Blocked => image dropped. |
allowedLinkPrefixes |
string[] |
['https://','http://'] |
Allowed prefixes for <a href>. Blocked => link text only. |
parseIncompleteMarkdown |
boolean |
true |
(Future toggle) Auto apply repair internally. Currently you repair outside using utility; prop reserved. |
shikiTheme |
string |
'github-light' |
Shiki theme to use for syntax highlighting (any loaded Shiki theme name). |
codeBlockActions |
Component[] |
[] |
Array of Vue components appended as action buttons in every code block header. |
codeBlockShowLineNumbers |
boolean |
false |
Show line numbers in all code fences. |
codeBlockSelectable |
boolean |
true |
Whether code text is selectable (adds select-none when false). |
codeBlockHideCopy |
boolean |
false |
Globally hide built‑in copy buttons (you can add your own via actions). |
codeBlockHideDownload |
boolean |
false |
Globally hide built‑in download buttons. |
All unrecognised props are ignored (no arbitrary HTML injection for safety).
<StreamMarkdown> does not expose custom slots for content fragments (the pipeline is AST-driven). To customize rendering you override tags via the components prop:
import type { Component } from 'vue';
import { StreamMarkdown } from 'streamdown-vue';
const FancyP: Component = {
setup(_, { slots }) { return () => h('p', { class: 'text-pink-600 font-serif' }, slots.default?.()); }
};
<StreamMarkdown :components="{ p: FancyP }" :content="md" />If a tag is missing from components it falls back to the built-in map.
Each semantic node receives a data-streamdown="name" attribute to make styling and querying reliable, even if classes are overridden:
| Element / Component | Data Attribute | Notes / Styling Hook |
|---|---|---|
Paragraph <p> |
p |
Base text blocks |
Anchor <a> |
a |
Hardened links (target+rel enforced) |
Inline code <code> |
inline-code |
Single backtick spans |
| Code block wrapper | code-block |
Outer container (header + body) |
| Code block header bar | code-block-header |
Holds language label + copy button |
| Code language badge | code-lang |
Language label span |
| Empty language placeholder | code-lang-empty |
Present when no language specified (reserved space) |
| Copy button | copy-button |
The actionable copy control |
| Code block body container | code-body |
Wraps highlighted <pre>; horizontal scroll applied here |
Code block element |
pre |
Added automatically to inner <pre> for targeting styles |
Code block element |
code |
Added automatically to inner <code> |
| Code line number span | code-line-number |
Present when line numbers enabled |
Unordered list <ul> |
ul |
|
Ordered list <ol> |
ol |
|
List item <li> |
li |
|
Horizontal rule <hr> |
hr |
|
Strong <strong> |
strong |
Bold emphasis |
Emphasis <em> |
em |
Italic emphasis |
Headings <h1>–<h6> |
h1 … h6 |
Each level individually tagged |
Blockquote <blockquote> |
blockquote |
|
Table <table> |
table |
Logical table element |
Table wrapper <div> |
table-wrapper |
Scroll container around table |
Table head <thead> |
thead |
|
Table body <tbody> |
tbody |
|
Table row <tr> |
tr |
|
Table header cell <th> |
th |
|
Table data cell <td> |
td |
|
Image <img> |
img |
Only if src passes hardening |
| Mermaid wrapper | mermaid |
Replaced with rendered SVG / diagram |
| KaTeX output | katex |
Class emitted by KaTeX (not set by us but styled via global KaTeX CSS) |
Because every semantic node has a stable data-streamdown marker, you can author zero‑collision styles (or component library themes) without relying on brittle tag chains. Example – customize the code block body and header:
/* Remove borders & add extra bottom padding inside code body */
.message-body :deep([data-streamdown='code-body']) pre {
border: none;
margin-bottom: 0;
padding-bottom: 30px;
}
/* Header bar tweaks */
.message-body :deep([data-streamdown='code-block-header']) {
background: linear-gradient(to right, #f5f5f5, #e8e8e8);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
/* Language badge */
.message-body :deep([data-streamdown='code-lang']) {
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Table wrapper scroll shadows */
.message-body :deep([data-streamdown='table-wrapper']) {
position: relative;
}
.message-body :deep([data-streamdown='table-wrapper']::after) {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 12px;
pointer-events: none;
background: linear-gradient(
to right,
rgba(255, 255, 255, 0),
rgba(0, 0, 0, 0.08)
);
}Tips:
- Scope via a parent (e.g.
.message-body) or component root to avoid leaking styles. - Use
:deep()(Vue SFC) /::v-deepwhere needed to pierce scoped boundaries. - Prefer attribute selectors over tag names so overrides survive internal structural changes.
- For dark mode, pair selectors with media queries or a
.darkancestor.
Testing example (Vitest / Bun):
expect(html).toContain('data-streamdown="inline-code"');Only absolute URLs starting with an allowed prefix pass. Steps:
- Resolve relative (
/x) againstdefaultOriginif provided. - Lowercase & check
javascript:scheme (blocked). - Check each allowed prefix (case-insensitive).
- If any fail, the element is dropped (link/text downgraded, image removed).
Example – allow only your CDN images & HTTPS links:
<StreamMarkdown
:allowed-link-prefixes="['https://']"
:allowed-image-prefixes="['https://cdn.example.com/']"
default-origin="https://example.com"
:content="md"
/>Code fences are rendered by the internal CodeBlock component:
```ts
const x: number = 1;
```Override with your custom block:
import { defineComponent, h } from 'vue';
import { useShikiHighlighter } from 'streamdown-vue';
const MyCode = defineComponent({
props: { code: { type: String, required: true }, language: { type: String, default: '' } },
async setup(props) {
const highlighter = await useShikiHighlighter();
const html = highlighter.codeToHtml(props.code, { lang: props.language || 'text', themes: { light: 'github-light', dark: 'github-dark' } });
return () => h('div', { class: 'my-code', innerHTML: html });
}
});
<StreamMarkdown :components="{ codeblock: MyCode }" />You can switch the built‑in highlighting theme via the shikiTheme prop (default: github-light):
<StreamMarkdown :content="md" shiki-theme="github-dark" />Any valid Shiki theme name you have available can be passed. If you need multiple themes based on dark/light mode, you can conditionally bind the prop:
<StreamMarkdown
:content="md"
:shiki-theme="isDark ? 'github-dark' : 'github-light'"
/>Note: The highlighter preloads a small set of common languages (ts, js, json, bash, python, diff, markdown, vue). Additional languages will be auto‑loaded by Shiki if requested.
CodeBlock now provides:
| Feature | Prop / Mechanism | Default | Notes |
|---|---|---|---|
| Copy button | hideCopy (boolean) |
false |
Uses Clipboard API; auto‑binds code via context. |
| Download button | hideDownload (boolean) |
false |
Generates file with inferred extension (lightweight mapping). |
| Line numbers | showLineNumbers (boolean) |
false |
Injects <span class="code-line-number" data-streamdown="code-line-number"> prefixes. |
| Selectability toggle | selectable (boolean) |
true |
Adds select-none on <pre> when disabled. |
| Per‑block custom actions | :actions="[MyBtn]" |
[] |
Array of components/render fns appended right of header. |
| Slot actions | <template #actions> |
— | Slot for ad‑hoc buttons (highest flexibility). |
| Global actions | App provide |
— | Provide once: app.provide(GLOBAL_CODE_BLOCK_ACTIONS, [MyBtn]). |
| Context access for actions | inject(CODE_BLOCK_META_KEY) |
— | Retrieve { code, language } without prop drilling. |
You normally only use <StreamMarkdown>; customize all code blocks via pass‑through props:
<StreamMarkdown
:content="md"
:code-block-actions="[MyShareButton]"
code-block-show-line-numbers
code-block-hide-download
/>Or override the internal code block entirely through components map (key: codeblock):
const Minimal = defineComponent({
props: { code: String, language: String },
setup(p) { return () => h('pre', [h('code', p.code)]) }
});
<StreamMarkdown :components="{ codeblock: Minimal }" :content="md" />Per instance:
<CodeBlock
:code="snippet"
language="ts"
:actions="[MyShareButton, MyRunButton]"
/>Or via named slot:
<CodeBlock :code="snippet" language="ts">
<template #actions>
<MyShareButton />
<MyRunButton />
</template>
</CodeBlock>Globally (main.ts):
import { GLOBAL_CODE_BLOCK_ACTIONS } from 'streamdown-vue';
app.provide(GLOBAL_CODE_BLOCK_ACTIONS, [MyShareButton]);Inside a custom button component you can access the current code & language without props:
import { defineComponent, inject } from 'vue';
import { CODE_BLOCK_META_KEY } from 'streamdown-vue';
export const MyShareButton = defineComponent({
setup() {
const meta = inject(CODE_BLOCK_META_KEY)!; // { code, language }
const share = () => navigator.share?.({ text: meta.code });
return () => <button onClick={share}>Share</button>;
},
});If you want a fully custom action bar:
<CodeBlock
:code="snippet"
language="ts"
hide-copy
hide-download
:actions="[MyShareButton]"
/>Line numbers render as <span class="code-line-number" data-line-number data-streamdown="code-line-number">. Example Tailwind tweaks:
[data-streamdown='code-body'] .code-line-number {
@apply text-gray-400 dark:text-gray-500 select-none;
}The default copy & download buttons can be selectively hidden while still using custom actions.
The default copy button uses the Clipboard API and toggles an icon for UX; the download button creates a Blob and triggers a synthetic click.
Fenced block:
```mermaid
graph TD;A-->B;B-->C;
```The MermaidBlock component handles:
- Deduplicated initialization
- Simple hash based caching
- Error fallback (last good diagram)
- Copy diagram source
You can override it via components if you need advanced theming.
StreamMarkdown automatically injects remark-math + rehype-katex unless you supply your own via the remarkPlugins prop. The built‑in configuration intentionally sets singleDollarTextMath: false so that plain currency like $390K or $80–140K is not misinterpreted as inline math (a common issue during streaming where a later $ closes a huge unintended span).
Supported by default:
- Display math:
$$ ... $$ - (If you add them) Inline math via
\( ... \)or by providing your ownremark-mathwith single‑dollar enabled.
If you really want $x + y$ style inline math, provide your own configured plugin tuple. When you do this the built‑in math plugin is skipped:
import remarkMath from 'remark-math';
<StreamMarkdown
:content="md"
:remark-plugins="[[remarkMath, { singleDollarTextMath: true }]]"
/>We still expose some light repair helpers you can (optionally) run yourself before streaming completes:
| Helper | Purpose (opt‑in) |
|---|---|
fixDollarSignMath |
(Optional) Escape truly stray $ you decide are currency, if desired. |
fixMatrix |
Ensure matrix environments have proper row \\ line breaks. |
Example (opt‑in):
import { fixMatrix, fixDollarSignMath } from 'streamdown-vue';
const safe = fixMatrix(fixDollarSignMath(markdown));In streaming scenarios prefer leaving dollar signs untouched; the default config already avoids accidental inline math.
Repairs incomplete constructs (unclosed **, _, `, ~~, $$ blocks, links/images) so partial buffers still render.
Tokenizes markdown into stable block strings; combining repaired buffer pieces reduces re‑parsing cost vs re‑feeding the whole document each keystroke.
Usage inside a stream loop (see Tutorial above). Both exported from package root.
- Debounce UI updates: apply repairs & re-render at ~30–60fps (e.g.
requestAnimationFrame). - Reuse a single
<StreamMarkdown>instance; change onlycontentprop. - Avoid running large custom remark/rehype plugins on every partial—they run on full text.
- If highlighting is heavy for enormous fences, lazy-replace code block component after final chunk.
- Use server-side rendering for initial payload to reduce Total Blocking Time.
Benchmarks (see docs/performance.md) show ~56ms render of the complex fixture under Bun (subject to change).
This section shows end‑to‑end integration in a Nuxt 3 project: installation, global registration, a streaming composable, and a server route that emits incremental Markdown.
npm i streamdown-vue
# or: bun add streamdown-vueCreate plugins/streamdown.client.ts (client only so Shiki & Mermaid load in browser):
// plugins/streamdown.client.ts
import 'katex/dist/katex.min.css'; // once globally
// (Optional) warm the Shiki highlighter so first code block is instant
import { useShikiHighlighter } from 'streamdown-vue';
useShikiHighlighter();Nuxt auto‑registers anything in plugins/. No manual config required unless you disabled auto import.
<!-- pages/index.vue -->
<template>
<div class="prose mx-auto p-6">
<StreamMarkdown :content="md" />
</div>
<footer class="text-xs opacity-60 mt-8">
Rendered with streamdown-vue
</footer>
</template>
<script setup lang="ts">
import { StreamMarkdown } from 'streamdown-vue';
const md =
'# Welcome to Nuxt\\n\\nThis **Markdown** is rendered *streamdown style*.';
</script>If you prefer auto‑import without explicit import each time, add an alias export file:
// components/StreamMarkdown.client.ts
export { StreamMarkdown as default } from 'streamdown-vue';Now <StreamMarkdown /> is available automatically (Nuxt scans components/).
In any page/component:
<StreamMarkdown
:content="md"
:allowed-link-prefixes="['https://', '/']"
:allowed-image-prefixes="['https://cdn.myapp.com/']"
default-origin="https://myapp.com"
/>Relative links (e.g. /about) will resolve against defaultOrigin then be validated.
Create a route that emits partial Markdown pieces:
// server/api/chat.get.ts
export default defineEventHandler(async (event) => {
const encoder = new TextEncoder();
const parts = [
'# Chat Log\n',
'\nHello **world',
'** from',
' streamed',
' markdown.',
];
const stream = new ReadableStream({
start(controller) {
let i = 0;
const tick = () => {
if (i < parts.length) {
controller.enqueue(encoder.encode(parts[i++]));
setTimeout(tick, 300);
} else controller.close();
};
tick();
},
});
setHeader(event, 'Content-Type', 'text/plain; charset=utf-8');
return stream; // Nuxt will send as a stream
});// composables/useStreamedMarkdown.ts
import { ref } from 'vue';
import { parseBlocks, parseIncompleteMarkdown } from 'streamdown-vue';
export function useStreamedMarkdown(url: string) {
const rendered = ref('');
const raw = ref('');
const start = async () => {
const res = await fetch(url);
const reader = res.body!.getReader();
let buf = '';
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
// repair, split, join
const repaired = parseIncompleteMarkdown(buf);
rendered.value = parseBlocks(repaired).join('');
raw.value = buf;
}
};
return { rendered, raw, start };
}<!-- pages/stream.vue -->
<template>
<button @click="start" class="border px-3 py-1 mb-4">Start Stream</button>
<StreamMarkdown :content="rendered" class="prose" />
</template>
<script setup lang="ts">
import { StreamMarkdown } from 'streamdown-vue';
import { useStreamedMarkdown } from '@/composables/useStreamedMarkdown';
const { rendered, start } = useStreamedMarkdown('/api/chat');
</script>- The stream loop runs only client-side; on first SSR render you may want a placeholder skeleton.
- Shiki highlighting of large code blocks happens client-side; if you need critical highlighted code for SEO, pre-process the markdown on the server and send the HTML (future enhancement: server highlight hook).
- Ensure Mermaid is only executed client-side (the provided plugin pattern handles this since the component executes render logic on mount).
| Symptom | Fix |
|---|---|
| Copy / Download button not showing | Ensure default CodeBlock not overridden or your custom block renders desired buttons (remove hideCopy / hideDownload). |
| Links stripped | Adjust allowed-link-prefixes / set default-origin to resolve relative paths first. |
| Images missing | Add CDN prefix to allowed-image-prefixes. |
| Flash of unstyled math | Confirm KaTeX CSS loaded in client plugin before first render. |
| High CPU on huge streams | Throttle updates (wrap repair/render in requestAnimationFrame or batch by char count). |
That’s it—Nuxt integration is essentially drop‑in plus an optional streaming composable.
| Goal | Snippet |
|---|---|
| AI Chat | Combine streaming buffer + <StreamMarkdown> (tutorial §4) |
| Restrict to CDN images | Set :allowed-image-prefixes |
Override <table> style |
:components="{ table: MyTable }" |
| Add custom remark plugin | :remark-plugins="[myRemark]" |
| Append footer paragraph automatically | remark plugin injecting node |
| Basic local Vue example | See examples/basic in repo |
Custom remark plugin skeleton:
const remarkAppend = () => (tree: any) => {
tree.children.push({ type: 'paragraph', children: [{ type: 'text', value: 'Tail note.' }] });
};
<StreamMarkdown :remark-plugins="[remarkAppend]" />Why repair outside instead of inside the component? Control & transparency. You can decide when to re-render; the component focuses on a deterministic AST transform.
Can I disable KaTeX or Mermaid? For now they are bundled if you use their fences. Future option could allow toggling; PRs welcome.
Does it sanitize HTML? Inline HTML is not allowed (passed through remark/rehype with allowDangerousHtml: false). Add a sanitizer plugin if you purposely enable raw HTML.
Dark mode highlighting? Shiki is initialized with both a light & dark theme; you can swap classes on a container and CSS variables from Shiki handle the rest.
bun install
bun test # run tests (fast)
bun run build # build library (types + bundles)PRs for: improved matrix handling, plugin toggles, directive support, performance instrumentation are appreciated.
Licensed under the Apache License, Version 2.0.
let buffer = '';
for await (const chunk of stream) {
buffer += chunk;
buffer = parseIncompleteMarkdown(buffer);
const blocks = parseBlocks(buffer);
state.markdown = blocks.join('');
}Happy streaming! 🚀