@wooshiiltd/streamdown-vue
v0.1.1
Published
A Vue 3 port of streamdown — markdown rendering designed for AI-powered streaming.
Readme
streamdown-vue
A Vue 3 markdown renderer designed for AI-powered streaming — the Vue port of streamdown.
Overview
Formatting Markdown is easy. Tokenized, partially-streamed Markdown is not. streamdown-vue is built specifically for streaming AI output: it splits markdown into stable blocks, repairs broken tokens (unfinished **bold, dangling [link](, half-typed <table>s), and only re-renders the last incomplete block as new tokens arrive. The other blocks stay referentially stable and skip re-render entirely.
It mirrors the React streamdown package's public surface where the shape transfers, swapping React components for Vue 3 ones (Composition API, defineComponent, <script setup>-friendly).
Features
- Streaming-optimized. Block-based incremental parsing; sibling blocks aren't re-rendered when new tokens land in the last block.
- Broken-markdown repair.
remendpatches incomplete tokens during the stream so partial output still renders cleanly. - GitHub Flavored Markdown. Tables, task lists, strikethrough, footnotes.
- Math (LaTeX via KaTeX). Optional
@streamdown/mathplugin. - Mermaid diagrams. Optional
@streamdown/mermaidplugin with pan-zoom + fullscreen + PNG/SVG/MMD export. - Code syntax highlighting (Shiki). Optional
@streamdown/codeplugin. Light + dark themes, line numbers, copy + download buttons. - CJK-friendly. Optional
@streamdown/cjkplugin with autolink boundary fixes. - Link safety. Modal that confirms before opening external links; pluggable
onLinkCheckfor allowlists. - Sanitization.
rehype-sanitize+rehype-hardendefaults;allowedTagsfor custom HTML. - RTL detection. Per-block first-strong-character algorithm for mixed-direction documents.
- Tailwind v4 prefix support.
prefix="tw"prependstw:to every utility class. - i18n. Override every UI label via
translations. - Custom renderers. Plug a Vue component in for any code-fence language (Graphviz, dot, custom diagrams).
Installation
pnpm add @wooshiiltd/streamdown-vue vue
# or: npm i @wooshiiltd/streamdown-vue vueOptional plugin packages:
pnpm add @streamdown/code shiki # syntax highlighting
pnpm add @streamdown/math katex # LaTeX equations
pnpm add @streamdown/mermaid mermaid # diagrams
pnpm add @streamdown/cjk # CJK text handlingTailwind setup
Tell Tailwind to scan the streamdown-vue package for utility classes. In your project's main CSS file:
@source "../node_modules/@wooshiiltd/streamdown-vue/dist/*.js";Add matching @source lines for any plugin packages you installed:
@source "../node_modules/@streamdown/code/dist/*.js";
@source "../node_modules/@streamdown/math/dist/*.js";
@source "../node_modules/@streamdown/mermaid/dist/*.js";
@source "../node_modules/@streamdown/cjk/dist/*.js";In a monorepo with hoisted node_modules, adjust the relative path. From apps/web/src/styles/main.css to a hoisted root:
@source "../../../../node_modules/@wooshiiltd/streamdown-vue/dist/*.js";Stylesheet
Import the bundled animation stylesheet once at app entry:
// main.ts
import "@wooshiiltd/streamdown-vue/styles.css";If you use the math plugin, also import KaTeX's CSS:
import "katex/dist/katex.min.css";Design tokens (CSS custom properties)
Components use a shadcn-style design token system. If you already use shadcn-vue, the variables are set up. Otherwise, add a minimal set to your global CSS:
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--border: oklch(0.922 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--sidebar: oklch(0.985 0 0);
--radius: 0.625rem;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--border: oklch(0.269 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--sidebar: oklch(0.205 0 0);
--radius: 0.625rem;
}Usage
Minimal example
<script setup lang="ts">
import { ref } from "vue";
import { Streamdown } from "@wooshiiltd/streamdown-vue";
import "@wooshiiltd/streamdown-vue/styles.css";
const markdown = ref(`# Hello
A **streaming** markdown renderer with [links](https://example.com), \`inline code\`, and:
- alpha
- beta
- gamma
\`\`\`ts
function greet(name: string) {
return \`hello, \${name}\`;
}
\`\`\`
`);
</script>
<template>
<Streamdown :is-animating="false">{{ markdown }}</Streamdown>
</template>Streaming from an LLM
streamdown-vue doesn't ship its own chat hook — give it the current text and toggle is-animating while tokens are still arriving:
<script setup lang="ts">
import { ref } from "vue";
import { Streamdown } from "@wooshiiltd/streamdown-vue";
import { code } from "@streamdown/code";
import { mermaid } from "@streamdown/mermaid";
import { math } from "@streamdown/math";
import "@wooshiiltd/streamdown-vue/styles.css";
import "katex/dist/katex.min.css";
const text = ref("");
const isAnimating = ref(false);
async function send(prompt: string) {
text.value = "";
isAnimating.value = true;
const res = await fetch("/api/chat", {
method: "POST",
body: JSON.stringify({ prompt }),
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
text.value += decoder.decode(value, { stream: true });
}
isAnimating.value = false;
}
</script>
<template>
<Streamdown
animated
:is-animating="isAnimating"
:plugins="{ code, mermaid, math }"
caret="block"
>{{ text }}</Streamdown>
</template>The animated flag fades in new word-spans as they arrive (already-rendered words skip re-animation). caret="block" shows a blinking caret at the end of the streamed output, hidden automatically inside incomplete code fences and tables.
Custom renderer for a language
Drop in a Vue component for any code-fence language:
<script setup lang="ts">
import { defineComponent, h } from "vue";
import { Streamdown, type CustomRenderer } from "@wooshiiltd/streamdown-vue";
const Graphviz = defineComponent({
props: { code: String, language: String, isIncomplete: Boolean },
setup(props) {
return () =>
h("div", { class: "graphviz" }, props.isIncomplete ? "…" : props.code);
},
});
const renderers: CustomRenderer[] = [
{ language: "dot", component: Graphviz },
];
const md = `\`\`\`dot\ndigraph { a -> b }\n\`\`\``;
</script>
<template>
<Streamdown :plugins="{ renderers }">{{ md }}</Streamdown>
</template>Static mode (non-streaming)
For one-shot rendering with no streaming features:
<Streamdown mode="static">{{ markdown }}</Streamdown>This skips block splitting + remend repair and renders the entire document as one tree.
API
<Streamdown> props
| prop | type | default | notes |
|---|---|---|---|
| children | string | "" | Markdown source — canonical input. The default-slot <Streamdown>{{ md }}</Streamdown> form also works for static usage, but for streaming where the source mutates in place (e.g. AI SDK / Pinia store doing part.text += chunk), pass via :children to guarantee reactivity. |
| mode | "static" \| "streaming" | "streaming" | Static mode renders one tree; streaming splits into blocks. |
| dir | "auto" \| "ltr" \| "rtl" | — | "auto" runs first-strong-character detection per block. |
| isAnimating | boolean | false | True while tokens are still arriving. Drives caret + animation skip. |
| animated | boolean \| AnimateOptions | — | Fade in word-spans as they arrive. Pass { animation, duration, easing, sep, stagger } for tuning. Default stagger: 40 (matches React) queues a delay per token, so a 100-word block keeps animating for ~4s after content stops growing. Drop to stagger: 0 if you don't want that tail on multi-block streams. |
| caret | "block" \| "circle" | — | Trailing caret while streaming. |
| parseIncompleteMarkdown | boolean | true | Run remend repair before block splitting. |
| normalizeHtmlIndentation | boolean | false | Strip 4+ space indentation that would otherwise become a code block. |
| plugins | PluginConfig | — | { code?, math?, mermaid?, cjk?, renderers? }. |
| controls | ControlsConfig | true | false to hide all action buttons; nested config per type (code, table, mermaid). |
| linkSafety | LinkSafetyConfig | { enabled: true } | Toggle the safety modal; provide onLinkCheck(url) => boolean \| Promise<boolean> for allowlists. |
| mermaid | MermaidOptions | — | { config?, errorComponent? } for diagram tuning + custom error UI. |
| lineNumbers | boolean | true | Code block line numbers. Per-fence override via noLineNumbers meta string. |
| shikiTheme | [ThemeInput, ThemeInput] | ["github-light", "github-dark"] | Light + dark Shiki themes. |
| allowedTags | Record<string, string[]> | — | Custom HTML tags + their permitted attributes. Pairs with literalTagContent. |
| literalTagContent | string[] | — | Tag names whose children render as plain text (no markdown). |
| translations | Partial<StreamdownTranslations> | — | Override every UI string. |
| icons | Partial<IconMap> | — | Replace any of the 10 default SVG icons with your own component. |
| prefix | string | — | Tailwind v4 prefix() support. prefix="tw" produces tw:flex etc. |
| className | string | — | Class on the root container. |
| components | Components | — | Override any default renderer ({ h1: MyHeading, code: MyCode, inlineCode: MyInlineCode, ... }). |
| rehypePlugins / remarkPlugins | PluggableList | defaults | Replaces the default plugin chain (matches React upstream). The default rehype chain is rehypeRaw + rehypeSanitize + rehypeHarden; if you pass your own rehypePlugins, you also drop sanitization and link hardening, so include them yourself or you'll be rendering unsanitized HTML. |
| BlockComponent | Component | Block | Replace the per-block renderer. |
| parseMarkdownIntoBlocksFn | (md) => string[] | parseMarkdownIntoBlocks | Custom block splitter. |
| onAnimationStart / onAnimationEnd | () => void | — | Fired when isAnimating flips. Suppressed in mode="static". |
Composables
import {
useStreamdownContext, // current ControlsConfig + isAnimating + theme + ...
useTranslations, // current StreamdownTranslations
useIcons, // current IconMap
useCn, // prefix-aware cn()
usePlugins, // current PluginConfig | null
useCodePlugin, // CodeHighlighterPlugin | null
useMermaidPlugin, // DiagramPlugin | null
useMathPlugin, // MathPlugin | null
useCjkPlugin, // CjkPlugin | null
useCustomRenderer, // CustomRenderer | null for a given language
useIsCodeFenceIncomplete, // true inside the last block when it has an unclosed fence
useCodeBlockContext, // { code: string } from the nearest CodeBlock
useDeferredRender, // IntersectionObserver-based lazy renderer for heavy children
useThrottledDebounce, // ref<T> with throttle + trailing debounce
} from "@wooshiiltd/streamdown-vue";Reusable building blocks
If you want pieces of the renderer without <Streamdown> orchestration:
import {
Markdown, // raw markdown → VNode (no streaming)
Block, // a single memoized block
hastToVNode, // HAST tree → Vue VNode
renderMarkdownToVNode, // string → VNode (cached unified processor)
parseMarkdownIntoBlocks, // string → string[]
detectTextDirection, // "ltr" | "rtl"
hasIncompleteCodeFence,
hasTable,
normalizeHtmlIndentation,
defaultUrlTransform,
defaultComponents, // the renderer map
CodeBlock, CodeBlockBody, CodeBlockContainer, CodeBlockHeader,
CodeBlockCopyButton, CodeBlockDownloadButton, CodeBlockSkeleton,
Mermaid, PanZoom,
MermaidDownloadDropdown, MermaidFullscreenButton, svgToPngBlob,
TableCopyDropdown, TableDownloadButton, TableDownloadDropdown,
TableFullscreenButton,
tableDataToCSV, tableDataToTSV, tableDataToMarkdown,
extractTableDataFromElement, escapeMarkdownTableCell,
ImageComponent, Anchor, LinkSafetyModal,
animate, createAnimatePlugin,
} from "@wooshiiltd/streamdown-vue";Differences from the React package
- Component model. Vue 3 Composition API;
defineComponentinstead of React function components. Most renderers are functionaldefineComponentcalls in.tsfiles (no.vueSFCs required). - Hooks → composables.
useState→ref,useMemo→computed,useEffect→watchEffect/onMounted/onBeforeUnmount,useContext→inject. The publicuseXxxnames match the React surface. - Context → provide/inject. Five
InjectionKey<T>symbols (STREAMDOWN_CONTEXT_KEY,PLUGIN_CONTEXT_KEY,ICON_CONTEXT_KEY,PREFIX_CONTEXT_KEY,TRANSLATIONS_CONTEXT_KEY) plusBLOCK_INCOMPLETE_CONTEXT_KEYandCODE_BLOCK_CONTEXT_KEY. Streamdown provides reactive proxies for context, icons, translations; refs for plugins + cn function. - HAST → VNode. A custom
hastToVNodewalker replaces React'shast-util-to-jsx-runtime. It mirrors that library's prop-translation, custom-component, and key-passing semantics, calling Vue'sh()instead ofjsx/jsxs. - Block memoization. A
shallowRef-backed cache keyed by[content, dir, isIncomplete, components, rehypePlugins, remarkPlugins, ...]mirrors React.memo's custom comparator. Sibling blocks skip re-render during streaming. useTransitionwas dropped. Vue's reactivity already batches; the streaming path usesnextTickand ref updates directly.- Async components.
defineAsyncComponentis used for the highlighted code body so Shiki loads on demand. Mermaid is eagerly imported (it ships as the diagram engine and was already in the main chunk viaMermaidFullscreenButton).
Plugins
Five workspace packages are framework-agnostic and shared with the React package:
@streamdown/code— Shiki-based syntax highlighter with token + theme caching.@streamdown/math— KaTeX viaremark-math+rehype-katex.@streamdown/mermaid— Mermaid wrapper with initialization caching.@streamdown/cjk—remark-cjk-friendlyplugins + autolink boundary fixes.remend— broken-markdown repair, dependency-free.
Pass them via the plugins prop:
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import { cjk } from "@streamdown/cjk";Status
v0.1.0, first published cut. 222 tests covering every documented prop, the HAST renderer, all default components, all plugin integration paths, and the streaming + animation flow. Vue 3.4+ peer dep, ESM-only, ~25 KB gzipped.
License
Apache-2.0. Source at https://github.com/wooshiiltd/streamdown-vue.
