
Streamdown
- 2.3k installs
- 5.4k repo stars
- Updated July 21, 2026
- vercel/streamdown
streamdown is a Vercel skill for configuring the Streamdown streaming React Markdown renderer and plugins.
About
The streamdown skill implements Vercel Streamdown, a streaming-optimized React Markdown renderer with syntax highlighting, Mermaid, math, and CJK support. Quick setup installs streamdown plus optional @streamdown/code, mermaid, math, and cjk plugins, then adds required Tailwind v4 @source lines or v3 content entries for dist JS scanning, a commonly missed step. Basic usage imports Streamdown with a markdown string; AI chat examples pair useChat from @ai-sdk/react with caret and isAnimating props on the final assistant message. Static mode suits blogs and docs without streaming indicators. Key props cover mode, plugins, controls, linkSafety, shikiTheme, allowedElements, and urlTransform with references for full API, styling, security, and features. Gotchas include importing katex CSS for math, requiring both caret and isAnimating for visible carets, disabled copy buttons during animation, and explicit shiki dependency for Next.js transpilePackages. Example files demonstrate basic streaming, caret, full-featured, static, and custom security configurations.
- Drop-in streaming Markdown renderer replacing react-markdown patterns.
- Requires Tailwind @source or content scanning of streamdown dist files.
- Integrates with Vercel AI SDK useChat via caret and isAnimating props.
- Optional plugins for code, Mermaid, math, and CJK with setup notes.
- Documents linkSafety, allowedElements, and Shiki theme customization.
Streamdown by the numbers
- 2,262 all-time installs (skills.sh)
- +59 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #205 of 2,277 Frontend Development skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
streamdown capabilities & compatibility
- Capabilities
- streamdown install and tailwind source configura · ai sdk usechat streaming integration pattern · plugin setup for code, mermaid, math, and cjk · linksafety and custom html tag hardening · static versus streaming mode configuration
- Works with
- vercel
- Use cases
- frontend · ui design · documentation
npx skills add https://github.com/vercel/streamdown --skill streamdownAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2.3k |
|---|---|
| repo stars | ★ 5.4k |
| Security audit | 1 / 3 scanners passed |
| Last updated | July 21, 2026 |
| Repository | vercel/streamdown ↗ |
How do I render streaming AI Markdown with syntax highlighting and safe links in React?
Implement Streamdown streaming React Markdown with plugins, Tailwind setup, AI SDK integration, and security controls.
Who is it for?
Next.js or React apps using Vercel AI SDK or streaming assistant Markdown output.
Skip if: Skip for non-React static site generators without a React Markdown component integration.
When should I use this skill?
User sets up Streamdown, streaming markdown, Shiki themes, Mermaid in chat, or link safety.
What you get
Streamdown configured with Tailwind sources, plugins, and AI chat streaming props working in production.
- Streamdown chat component
- Plugin configuration
- Streaming markdown UI
By the numbers
- 4 optional tree-shakeable plugins: code, mermaid, math, and cjk
- Drop-in replacement for react-markdown with remend unterminated-block parsing
Files
Streamdown
Streaming-optimized React Markdown renderer. Drop-in replacement for react-markdown with built-in streaming support, security, and interactive controls.
Quick Setup
1. Install
npm install streamdownOptional plugins (install only what's needed):
npm install @streamdown/code @streamdown/mermaid @streamdown/math @streamdown/cjk2. Configure Tailwind CSS (Required)
This is the most commonly missed step. Streamdown uses Tailwind for styling and the dist files must be scanned.
Tailwind v4 — add to globals.css:
@source "../node_modules/streamdown/dist/*.js";Add plugin @source lines only for packages you have installed (omitting uninstalled plugins avoids Tailwind errors). See plugin pages for exact paths:
- Code:
@source "../node_modules/@streamdown/code/dist/*.js"; - CJK:
@source "../node_modules/@streamdown/cjk/dist/*.js"; - Math:
@source "../node_modules/@streamdown/math/dist/*.js"; - Mermaid:
@source "../node_modules/@streamdown/mermaid/dist/*.js";
Tailwind v3 — add to tailwind.config.js:
module.exports = {
content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./node_modules/streamdown/dist/*.js",
],
};3. Basic Usage
import { Streamdown } from 'streamdown';
<Streamdown>{markdown}</Streamdown>4. With AI Streaming (Vercel AI SDK)
'use client';
import { useChat } from '@ai-sdk/react';
import { Streamdown } from 'streamdown';
import { code } from '@streamdown/code';
export default function Chat() {
const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat();
return (
<>
{messages.map((msg, i) => (
<Streamdown
key={msg.id}
plugins={{ code }}
caret="block"
isAnimating={isLoading && i === messages.length - 1 && msg.role === 'assistant'}
>
{msg.content}
</Streamdown>
))}
<form onSubmit={handleSubmit}>
<input value={input} onChange={handleInputChange} disabled={isLoading} />
</form>
</>
);
}5. Static Mode (Blogs, Docs)
<Streamdown mode="static" plugins={{ code }}>
{content}
</Streamdown>Key Props
| Prop | Type | Default | Purpose |
|---|---|---|---|
children | string | — | Markdown content |
mode | `"streaming" \ | "static"` | "streaming" |
plugins | { code?, mermaid?, math?, cjk? } | — | Feature plugins |
isAnimating | boolean | false | Streaming indicator |
caret | `"block" \ | "circle"` | — |
components | Components | — | Custom element overrides |
controls | `boolean \ | object` | true |
linkSafety | LinkSafetyConfig | { enabled: true } | Link confirmation modal |
shikiTheme | [light, dark] | ['github-light', 'github-dark'] | Code themes |
className | string | — | Container class |
allowedElements | string[] | all | Tag names to allow |
disallowedElements | string[] | [] | Tag names to disallow |
allowElement | AllowElement | — | Custom element filter |
unwrapDisallowed | boolean | false | Keep children of disallowed elements |
skipHtml | boolean | false | Ignore raw HTML |
urlTransform | UrlTransform | defaultUrlTransform | Transform/sanitize URLs |
For full API reference, see references/api.md.
Plugin Quick Reference
| Plugin | Package | Purpose |
|---|---|---|
| Code | @streamdown/code | Syntax highlighting (Shiki, 200+ languages) |
| Mermaid | @streamdown/mermaid | Diagrams (flowcharts, sequence, etc.) |
| Math | @streamdown/math | LaTeX via KaTeX (requires CSS import) |
| CJK | @streamdown/cjk | Chinese/Japanese/Korean text support |
Math requires CSS:
import 'katex/dist/katex.min.css';For plugin configuration details, see references/plugins.md.
References
Use these for deeper implementation details:
- [references/api.md](references/api.md) — Complete props, types, and interfaces
- [references/plugins.md](references/plugins.md) — Plugin setup, configuration, and customization
- [references/styling.md](references/styling.md) — CSS variables, data attributes, custom components, theme examples
- [references/security.md](references/security.md) — Hardening, link safety, custom HTML tags, production config
- [references/features.md](references/features.md) — Carets, remend, static mode, controls, GFM, memoization, troubleshooting
Example Configurations
Copy and adapt from assets/examples/:
- [basic-streaming.tsx](assets/examples/basic-streaming.tsx) — Minimal AI chat with Vercel AI SDK
- [with-caret.tsx](assets/examples/with-caret.tsx) — Streaming with block caret cursor
- [full-featured.tsx](assets/examples/full-featured.tsx) — All plugins, carets, link safety, controls
- [static-mode.tsx](assets/examples/static-mode.tsx) — Blog/docs rendering
- [custom-security.tsx](assets/examples/custom-security.tsx) — Strict security for AI content
Common Gotchas
1. Tailwind styles missing — Add @source directive or content entry for node_modules/streamdown/dist/*.js 2. Math not rendering — Import katex/dist/katex.min.css 3. Caret not showing — Both caret prop AND isAnimating={true} are required 4. Copy buttons during streaming — Disabled automatically when isAnimating={true} 5. Link safety modal appearing — Enabled by default; disable with linkSafety={{ enabled: false }} 6. Shiki warning in Next.js — Install shiki explicitly, add to transpilePackages 7. `allowedTags` not working — Only works with default rehype plugins 8. Math uses `$$` not `$` — Single dollar is disabled by default to avoid currency conflicts
"use client";
import { useChat } from "@ai-sdk/react";
import { Streamdown } from "streamdown";
export default function ChatPage() {
const { messages, input, handleInputChange, handleSubmit, isLoading } =
useChat();
return (
<div className="flex h-screen flex-col">
<div className="flex-1 space-y-4 overflow-y-auto p-4">
{messages.map((message) => (
<div
className={message.role === "user" ? "text-right" : "text-left"}
key={message.id}
>
<div className="inline-block max-w-2xl">
<Streamdown
isAnimating={isLoading && message.role === "assistant"}
>
{message.content}
</Streamdown>
</div>
</div>
))}
</div>
<form className="border-t p-4" onSubmit={handleSubmit}>
<input
className="w-full rounded-lg border px-4 py-2"
disabled={isLoading}
onChange={handleInputChange}
placeholder="Ask me anything..."
value={input}
/>
</form>
</div>
);
}
"use client";
import { code } from "@streamdown/code";
import { defaultRehypePlugins, Streamdown } from "streamdown";
// Strict security config for AI-generated content
const rehypePlugins = [
defaultRehypePlugins.raw,
defaultRehypePlugins.sanitize,
[
defaultRehypePlugins.harden[0],
{
allowedProtocols: ["https", "mailto"],
allowedLinkPrefixes: [
"https://your-domain.com",
"https://docs.your-domain.com",
],
allowedImagePrefixes: ["https://cdn.your-domain.com"],
allowDataImages: false,
},
],
];
export default function SecureChat({ content }: { content: string }) {
return (
<Streamdown
linkSafety={{
enabled: true,
onLinkCheck: (url) => {
const trusted = ["your-domain.com"];
const hostname = new URL(url).hostname;
return trusted.some((d) => hostname.endsWith(d));
},
}}
plugins={{ code }}
rehypePlugins={rehypePlugins}
>
{content}
</Streamdown>
);
}
"use client";
import { useChat } from "@ai-sdk/react";
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import { Streamdown } from "streamdown";
import "katex/dist/katex.min.css";
export default function FullFeaturedChat() {
const { messages, input, handleInputChange, handleSubmit, isLoading } =
useChat();
return (
<div className="flex h-screen flex-col">
<div className="flex-1 space-y-4 overflow-y-auto p-4">
{messages.map((message, index) => (
<div key={message.id}>
<Streamdown
caret="block"
controls={{
code: true,
table: true,
mermaid: {
download: true,
copy: true,
fullscreen: true,
panZoom: true,
},
}}
isAnimating={
isLoading &&
index === messages.length - 1 &&
message.role === "assistant"
}
linkSafety={{
enabled: true,
onLinkCheck: (url) => {
const trusted = ["github.com", "npmjs.com"];
const hostname = new URL(url).hostname;
return trusted.some((d) => hostname.endsWith(d));
},
}}
plugins={{ code, mermaid, math }}
>
{message.content}
</Streamdown>
</div>
))}
</div>
<form className="border-t p-4" onSubmit={handleSubmit}>
<input
className="w-full rounded-lg border px-4 py-2"
disabled={isLoading}
onChange={handleInputChange}
placeholder="Ask me anything..."
value={input}
/>
</form>
</div>
);
}
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { Streamdown } from "streamdown";
import "katex/dist/katex.min.css";
export default function BlogPost({ content }: { content: string }) {
return (
<Streamdown
linkSafety={{ enabled: false }}
mode="static"
plugins={{ code, math }}
shikiTheme={["github-light", "github-dark"]}
>
{content}
</Streamdown>
);
}
"use client";
import { useChat } from "@ai-sdk/react";
import { code } from "@streamdown/code";
import { Streamdown } from "streamdown";
export default function ChatWithCaret() {
const { messages, input, handleInputChange, handleSubmit, isLoading } =
useChat();
return (
<div className="flex h-screen flex-col">
<div className="flex-1 space-y-4 overflow-y-auto p-4">
{messages.map((message, index) => (
<div key={message.id}>
<Streamdown
caret="block"
isAnimating={
isLoading &&
index === messages.length - 1 &&
message.role === "assistant"
}
plugins={{ code }}
>
{message.content}
</Streamdown>
</div>
))}
</div>
<form className="border-t p-4" onSubmit={handleSubmit}>
<input
className="w-full rounded-lg border px-4 py-2"
disabled={isLoading}
onChange={handleInputChange}
placeholder="Ask me anything..."
value={input}
/>
</form>
</div>
);
}
Streamdown API Reference
Table of Contents
StreamdownProps
interface StreamdownProps {
// Core
children: string;
mode?: "streaming" | "static"; // default: "streaming"
parseIncompleteMarkdown?: boolean; // default: true
remend?: RemendOptions;
isAnimating?: boolean; // default: false
className?: string;
// Styling
shikiTheme?: [BundledTheme, BundledTheme]; // default: ['github-light', 'github-dark']
components?: Components; // Custom element overrides
allowedTags?: Record<string, string[]>; // Custom HTML tags (only with default rehype plugins)
// Plugins
plugins?: PluginConfig;
rehypePlugins?: Pluggable[]; // default: [rehype-raw, rehype-sanitize, rehype-harden]
remarkPlugins?: Pluggable[]; // default: [remark-gfm]
// Element Filtering (react-markdown compatible)
allowedElements?: string[]; // Tag names to allow (cannot combine with disallowedElements)
disallowedElements?: string[]; // Tag names to disallow (cannot combine with allowedElements)
allowElement?: AllowElement; // Custom filter callback
unwrapDisallowed?: boolean; // default: false — replace disallowed with children
skipHtml?: boolean; // default: false — ignore raw HTML
urlTransform?: UrlTransform; // default: defaultUrlTransform — transform/sanitize URLs
// Features
caret?: "block" | "circle";
controls?: ControlsConfig; // default: true
mermaid?: MermaidOptions;
linkSafety?: LinkSafetyConfig; // default: { enabled: true }
cdnUrl?: string | null; // default: 'https://streamdown.ai/cdn'
// Advanced
BlockComponent?: React.ComponentType<BlockProps>;
parseMarkdownIntoBlocksFn?: (markdown: string) => string[];
}PluginConfig
interface PluginConfig {
code?: CodeHighlighterPlugin;
mermaid?: DiagramPlugin;
math?: MathPlugin;
cjk?: CjkPlugin;
}CodeHighlighterPlugin (@streamdown/code)
import { code } from '@streamdown/code';
import { createCodePlugin } from '@streamdown/code';
const code = createCodePlugin({
themes: ['github-light', 'github-dark'], // [light, dark]
});
// Methods:
code.highlight(options, callback?);
code.supportsLanguage(language: string): boolean;
code.getSupportedLanguages(): string[];
code.getThemes(): [BundledTheme, BundledTheme];DiagramPlugin (@streamdown/mermaid)
import { mermaid } from '@streamdown/mermaid';
import { createMermaidPlugin } from '@streamdown/mermaid';
const mermaid = createMermaidPlugin({
config: {
theme: 'dark', // 'default' | 'dark' | 'forest' | 'neutral' | 'base'
fontFamily: 'monospace',
},
});MathPlugin (@streamdown/math)
import { math } from '@streamdown/math';
import { createMathPlugin } from '@streamdown/math';
const math = createMathPlugin({
singleDollarTextMath: true, // default: false
errorColor: '#ff0000',
});
math.getStyles(); // Returns "katex/dist/katex.min.css"Math requires CSS import:
import 'katex/dist/katex.min.css';CjkPlugin (@streamdown/cjk)
import { cjk } from '@streamdown/cjk';
import { createCjkPlugin } from '@streamdown/cjk';
const cjk = createCjkPlugin();
// Provides remarkPluginsBefore and remarkPluginsAfterRemendOptions
Controls how incomplete Markdown is completed during streaming.
interface RemendOptions {
links?: boolean; // default: true
images?: boolean; // default: true
bold?: boolean; // default: true
italic?: boolean; // default: true
boldItalic?: boolean; // default: true
inlineCode?: boolean; // default: true
strikethrough?: boolean; // default: true
katex?: boolean; // default: true
setextHeadings?: boolean; // default: true
linkMode?: 'protocol' | 'text-only'; // default: 'protocol'
handlers?: RemendHandler[]; // Custom handlers
}ControlsConfig
type ControlsConfig = boolean | {
table?: boolean;
code?: boolean;
mermaid?: boolean | {
download?: boolean;
copy?: boolean;
fullscreen?: boolean;
panZoom?: boolean;
};
};LinkSafetyConfig
interface LinkSafetyConfig {
enabled: boolean;
onLinkCheck?: (url: string) => Promise<boolean> | boolean;
renderModal?: (props: LinkSafetyModalProps) => React.ReactNode;
}
interface LinkSafetyModalProps {
url: string;
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
}MermaidOptions
interface MermaidOptions {
config?: MermaidConfig;
errorComponent?: React.ComponentType<MermaidErrorComponentProps>;
}
interface MermaidErrorComponentProps {
error: string;
chart: string;
retry: () => void;
}Element Filtering Types
type AllowElement = (
element: Readonly<Element>,
index: number,
parent: Readonly<Parents> | undefined
) => boolean | null | undefined;
type UrlTransform = (
url: string,
key: string,
node: Readonly<Element>
) => string | null | undefined;defaultUrlTransform
Passthrough function that returns URLs unchanged. URL security is handled by rehype-sanitize and rehype-harden instead. Use urlTransform when you need custom URL rewriting.
import { defaultUrlTransform } from 'streamdown';
defaultUrlTransform('https://example.com', 'href', node); // 'https://example.com'
defaultUrlTransform('/relative/path', 'href', node); // '/relative/path'Default Exports
import {
Streamdown,
defaultUrlTransform, // URL passthrough (security handled by rehype plugins)
defaultRemarkPlugins, // { gfm: [remarkGfm, {}] }
defaultRehypePlugins, // { raw: rehypeRaw, sanitize: [rehypeSanitize, {}], harden: [harden, {...}] }
} from 'streamdown';
// Types
import type {
AllowElement,
Components,
ExtraProps,
UrlTransform,
} from 'streamdown';Overridable Components
Pass via components prop:
| Element | Props |
|---|---|
h1-h6 | children, className, node |
p | children, className, node |
strong, em | children, className, node |
ul, ol | children, className, node |
li | children, className, node |
a | children, className, href, node |
code, pre | children, className, node |
blockquote | children, className, node |
table, thead, tbody, tr, th, td | children, className, node |
img | src, alt, className, node |
hr | className, node |
sup, sub | children, className, node |
section | children, className, node |
Custom HTML Tags
<Streamdown
allowedTags={{
source: ["id"],
mention: ["user_id", "type"],
widget: ["data*"], // wildcard: all data-* attributes
}}
components={{
source: (props) => <Badge>{props.id}</Badge>,
mention: (props) => <UserMention userId={props.user_id} />,
}}
>
{markdown}
</Streamdown>Note: allowedTags only works when using default rehype plugins.
Streamdown Features Reference
Table of Contents
- Streaming Mode vs Static Mode
- Carets
- Remend (Incomplete Markdown)
- Interactive Controls
- GFM Features
- Memoization & Performance
- Troubleshooting
Streaming Mode vs Static Mode
Streaming (default): Splits markdown into blocks, applies remend, supports carets, memoizes blocks individually.
<Streamdown isAnimating={isLoading}>{streamingContent}</Streamdown>Static: Renders as single unit, skips streaming optimizations. Use for blog posts, docs, pre-generated content.
<Streamdown mode="static">{completeContent}</Streamdown>All props (plugins, components, themes) work in both modes.
Carets
Visual cursor at end of streaming content.
// Block caret (▋) - terminal style
<Streamdown caret="block" isAnimating={isLoading}>{content}</Streamdown>
// Circle caret (●) - subtle style
<Streamdown caret="circle" isAnimating={isLoading}>{content}</Streamdown>Requirements: Both caret prop AND isAnimating={true} must be set. Caret disappears when streaming stops.
Per-message in chat:
{messages.map((msg, i) => (
<Streamdown
key={msg.id}
caret="block"
isAnimating={isLoading && i === messages.length - 1 && msg.role === 'assistant'}
>
{msg.content}
</Streamdown>
))}Remend
Preprocessor that completes incomplete Markdown during streaming.
What it handles:
| Pattern | Completion |
|---|---|
**text | **text** |
*text | *text* |
` code `` | ` code ` |
~~text | ~~text~~ |
[text | [text](streamdown:incomplete-link) or text |
![alt | Removed entirely |
$$\n math | $$\n math $$ |
Disable:
<Streamdown parseIncompleteMarkdown={false}>{content}</Streamdown>Configure:
<Streamdown
remend={{
bold: true,
italic: true,
links: true,
images: true,
inlineCode: true,
strikethrough: true,
katex: true,
linkMode: 'text-only', // 'protocol' | 'text-only'
}}
>Custom handlers:
<Streamdown
remend={{
handlers: [{
name: 'custom-syntax',
handle: (text) => {
if (text.endsWith('<<')) return text + '>>';
return null; // Return null to skip
},
priority: 100, // Lower = earlier (built-ins use 0-70)
}],
}}
>Interactive Controls
Auto-added buttons for images, tables, code, and Mermaid.
Disable all:
<Streamdown controls={false}>{markdown}</Streamdown>Selective:
<Streamdown
controls={{
table: true,
code: false, // No copy/download on code blocks
mermaid: {
download: true,
copy: true,
fullscreen: true,
panZoom: false,
},
}}
>Button types by element:
- Images: Download (auto-detected format, alt text as filename)
- Tables: Copy (CSV/TSV/HTML), Download (CSV/Markdown)
- Code blocks: Copy (raw code), Download (with correct extension)
- Mermaid: Copy (source), Download (SVG), Fullscreen, Pan/zoom
All buttons disabled during streaming when isAnimating={true}.
GFM Features
Included by default via remark-gfm:
Tables:
| Left | Center | Right |
|:-----|:------:|------:|
| A | B | C |Task lists:
- [x] Completed
- [ ] PendingStrikethrough: ~~deleted~~
Autolinks: URLs and emails auto-linked.
Footnotes: [^1] reference, [^1]: definition.
Memoization & Performance
- Component-level:
React.memoon Streamdown, re-renders only on children/shikiTheme/isAnimating changes - Block-level: Content split into blocks, each memoized individually
- Syntax highlighting: Cached tokens, lazy-loaded languages, shared highlighter instance
- Plugin arrays: Created once at module level
Troubleshooting
Shiki external package warning (Next.js)
npm install shiki// next.config.js
{ transpilePackages: ['shiki'] }Vite SSR CSS loading error
// vite.config.js
{ ssr: { noExternal: ['streamdown'] } }vscode-jsonrpc bundling errors (Next.js)
// next.config.js
{
serverComponentsExternalPackages: ['vscode-jsonrpc'],
webpack: (config) => {
config.resolve.alias['vscode-jsonrpc'] = false;
return config;
},
}Tailwind styles not applied
Ensure Streamdown dist files are included in Tailwind content scanning. See the Tailwind setup in SKILL.md.
Streamdown Plugins Reference
Table of Contents
- Plugin Overview
- @streamdown/code
- @streamdown/mermaid
- @streamdown/math
- @streamdown/cjk
- Built-in Remark Plugins
- Built-in Rehype Plugins
- Customizing Built-in Plugins
Plugin Overview
Each plugin is a standalone package. Install only what's needed:
npm install @streamdown/code @streamdown/mermaid @streamdown/math @streamdown/cjkimport { code } from '@streamdown/code';
import { mermaid } from '@streamdown/mermaid';
import { math } from '@streamdown/math';
import { cjk } from '@streamdown/cjk';
import 'katex/dist/katex.min.css'; // Required for math
<Streamdown plugins={{ code, mermaid, math, cjk }}>
{markdown}
</Streamdown>@streamdown/code
Syntax highlighting via Shiki. Supports 200+ languages (lazy-loaded on demand).
Install:
npm install @streamdown/codeDefault usage:
import { code } from '@streamdown/code';
<Streamdown plugins={{ code }}>{markdown}</Streamdown>Custom themes:
import { createCodePlugin } from '@streamdown/code';
const code = createCodePlugin({
themes: ['github-light', 'github-dark'], // [light, dark]
});Theme is also configurable via the shikiTheme prop on Streamdown:
<Streamdown plugins={{ code }} shikiTheme={['one-light', 'one-dark-pro']}>
{markdown}
</Streamdown>Features:
- Copy button on hover (disabled during streaming)
- Download button with correct file extension
- 200+ languages: js, ts, python, go, java, rust, c, cpp, ruby, php, swift, kotlin, etc.
- Token caching for performance
- Lazy language loading
Streaming behavior: Unterminated code blocks are gracefully handled by remend. Copy/download buttons disabled when isAnimating={true}.
Common issue — Shiki external package warning: If using Next.js and seeing warnings, install shiki explicitly and add to next.config.js:
transpilePackages: ['shiki'],@streamdown/mermaid
Interactive Mermaid diagrams.
Install:
npm install @streamdown/mermaidDefault usage:
import { mermaid } from '@streamdown/mermaid';
<Streamdown plugins={{ mermaid }}>{markdown}</Streamdown>Custom config:
import { createMermaidPlugin } from '@streamdown/mermaid';
const mermaid = createMermaidPlugin({
config: {
theme: 'dark', // 'default' | 'dark' | 'forest' | 'neutral' | 'base'
fontFamily: 'monospace',
},
});Mermaid options on Streamdown:
<Streamdown
plugins={{ mermaid }}
mermaid={{
config: { theme: 'neutral' },
errorComponent: ({ error, chart, retry }) => (
<div>
<p>Failed to render: {error}</p>
<button onClick={retry}>Retry</button>
</div>
),
}}
>
{markdown}
</Streamdown>Supported diagram types: Flowcharts, sequence, state, class, pie, Gantt, ER, git graphs.
Interactive controls: Fullscreen, download SVG, copy source, pan/zoom. Customize via controls prop:
<Streamdown
plugins={{ mermaid }}
controls={{
mermaid: { download: true, copy: true, fullscreen: true, panZoom: false },
}}
>Streaming behavior: Diagrams render as code blocks until the mermaid block is complete.
@streamdown/math
LaTeX math via KaTeX.
Install:
npm install @streamdown/mathUsage (CSS import required):
import { math } from '@streamdown/math';
import 'katex/dist/katex.min.css';
<Streamdown plugins={{ math }}>{markdown}</Streamdown>Custom config:
import { createMathPlugin } from '@streamdown/math';
const math = createMathPlugin({
singleDollarTextMath: true, // Enable $...$ syntax (default: false)
errorColor: '#ff0000',
});Syntax:
- Inline:
$$E = mc^2$$(same line) - Block:
$$\nE = mc^2\n$$(separate lines) - Default uses double
$$only (not single$) to avoid conflicts with currency
Streaming behavior: Incomplete $$ blocks are auto-closed by remend.
@streamdown/cjk
Chinese, Japanese, Korean text support.
Install:
npm install @streamdown/cjkUsage:
import { cjk } from '@streamdown/cjk';
<Streamdown plugins={{ cjk }}>{markdown}</Streamdown>What it fixes:
- Emphasis markers adjacent to ideographic punctuation (bold, italic, strikethrough)
- Autolinks swallowing trailing CJK punctuation
Supported punctuation: 。.,、?!:;()【】「」『』〈〉《》
Built-in Remark Plugins
remark-gfm — GitHub Flavored Markdown:
- Tables (with alignment)
- Task lists (
- [ ],- [x]) - Strikethrough (
~~text~~) - Autolinks
- Footnotes (
[^1])
Built-in Rehype Plugins
1. rehype-raw — Preserves raw HTML in Markdown 2. rehype-sanitize — XSS protection 3. rehype-harden — URL/protocol restrictions:
{
allowedImagePrefixes: ['*'],
allowedLinkPrefixes: ['*'],
allowedProtocols: ['*'],
defaultOrigin: undefined,
allowDataImages: true,
}Customizing Built-in Plugins
import { defaultRemarkPlugins, defaultRehypePlugins } from 'streamdown';
// Add custom plugins alongside defaults
<Streamdown
remarkPlugins={[...Object.values(defaultRemarkPlugins), myCustomPlugin]}
rehypePlugins={[...Object.values(defaultRehypePlugins), anotherPlugin]}
>
{markdown}
</Streamdown>Disable HTML entirely by omitting rehype-raw:
const { raw, ...rest } = defaultRehypePlugins;
<Streamdown rehypePlugins={Object.values(rest)}>{markdown}</Streamdown>Streamdown Security Reference
Table of Contents
- Default Security Posture
- Restricting Protocols
- Restricting Links
- Restricting Images
- Link Safety Modal
- Custom HTML Tags
- Disabling HTML
- Production Config Example
Default Security Posture
Streamdown is permissive by default (all prefixes, protocols, and data images allowed). Security is provided by:
1. rehype-sanitize — XSS prevention 2. rehype-harden — URL/protocol restriction (all allowed by default) 3. Link safety modal — Confirmation before opening external links (enabled by default)
Restricting Protocols
<Streamdown
rehypePlugins={[
rehypeRaw,
[rehypeSanitize, {}],
[harden, {
allowedProtocols: ['https', 'mailto'],
allowedLinkPrefixes: ['*'],
allowedImagePrefixes: ['*'],
allowDataImages: true,
}],
]}
>Restricting Links
Only allow specific domains:
[harden, {
allowedLinkPrefixes: [
'https://example.com',
'https://docs.example.com',
],
allowedProtocols: ['https'],
}]Restricting Images
[harden, {
allowedImagePrefixes: [
'https://images.example.com',
'https://cdn.example.com',
],
allowDataImages: false, // Disable data: URLs
}]Link Safety Modal
Default: Enabled. Shows confirmation before opening links.
Disable:
<Streamdown linkSafety={{ enabled: false }}>{markdown}</Streamdown>Safelist trusted domains:
<Streamdown
linkSafety={{
enabled: true,
onLinkCheck: async (url) => {
const trusted = ['example.com', 'docs.example.com'];
const hostname = new URL(url).hostname;
return trusted.some((d) => hostname.endsWith(d));
},
}}
>Custom modal:
<Streamdown
linkSafety={{
enabled: true,
renderModal: ({ url, isOpen, onClose, onConfirm }) => (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<p>Open {url}?</p>
<Button onClick={onConfirm}>Continue</Button>
<Button onClick={onClose}>Cancel</Button>
</DialogContent>
</Dialog>
),
}}
>Custom HTML Tags
Whitelist specific tags and attributes for AI-generated custom elements:
<Streamdown
allowedTags={{
source: ["id"],
mention: ["user_id", "type"],
widget: ["data*"], // Wildcard: all data-* attributes
}}
components={{
source: (props) => <Badge>{props.id}</Badge>,
mention: (props) => <UserMention userId={props.user_id} />,
}}
>Important: allowedTags only works with default rehype plugins. Custom rehypePlugins require custom sanitization.
URL Transform
Use the urlTransform prop for custom URL rewriting. The default defaultUrlTransform is a passthrough — URL security is handled by rehype-sanitize and rehype-harden.
import { Streamdown, defaultUrlTransform } from 'streamdown';
// Proxy images through your CDN
<Streamdown
urlTransform={(url, key, node) => {
if (key === 'src') {
return `https://your-cdn.com/proxy?url=${encodeURIComponent(url)}`;
}
return defaultUrlTransform(url, key, node);
}}
>
{markdown}
</Streamdown>Skipping HTML
Completely ignore raw HTML in Markdown with skipHtml:
<Streamdown skipHtml>{markdown}</Streamdown>Disabling HTML
Remove rehype-raw to block all raw HTML:
import { defaultRehypePlugins } from 'streamdown';
const { raw, ...rest } = defaultRehypePlugins;
<Streamdown rehypePlugins={Object.values(rest)}>{markdown}</Streamdown>Production Config Example
Strict config for AI-generated content:
<Streamdown
rehypePlugins={[
rehypeRaw,
[rehypeSanitize, {}],
[harden, {
allowedProtocols: ['https', 'mailto'],
allowedLinkPrefixes: ['https://your-domain.com'],
allowedImagePrefixes: ['https://your-cdn.com'],
allowDataImages: false,
}],
]}
linkSafety={{
enabled: true,
onLinkCheck: async (url) => {
const trusted = ['your-domain.com'];
return trusted.some((d) => new URL(url).hostname.endsWith(d));
},
}}
>
{aiContent}
</Streamdown>Streamdown Styling Reference
Table of Contents
CSS Variables
Streamdown uses shadcn/ui CSS variables. Override in globals.css:
@layer base {
:root {
--primary: 222.2 47.4% 11.2%; /* Links, accents */
--primary-foreground: 210 40% 98%; /* Text on primary */
--muted: 210 40% 96.1%; /* Code blocks, table headers */
--muted-foreground: 215.4 16.3% 46.9%; /* Blockquote text */
--border: 214.3 31.8% 91.4%; /* Tables, rules, code blocks */
--ring: 222.2 84% 4.9%; /* Focus rings */
--radius: 0.5rem; /* Border radius */
}
.dark {
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--border: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}Data Attribute Selectors
Target specific elements via [data-streamdown="..."]:
/* Headings */
[data-streamdown="heading-1"] { }
[data-streamdown="heading-2"] { }
[data-streamdown="heading-3"] { }
[data-streamdown="heading-4"] { }
[data-streamdown="heading-5"] { }
[data-streamdown="heading-6"] { }
/* Text */
[data-streamdown="strong"] { }
[data-streamdown="link"] { }
[data-streamdown="inline-code"] { }
/* Lists */
[data-streamdown="ordered-list"] { }
[data-streamdown="unordered-list"] { }
[data-streamdown="list-item"] { }
/* Blocks */
[data-streamdown="blockquote"] { }
[data-streamdown="horizontal-rule"] { }
/* Code */
[data-streamdown="code-block"] { }
[data-streamdown="mermaid-block"] { }
/* Tables */
[data-streamdown="table-wrapper"] { }
[data-streamdown="table"] { }
[data-streamdown="table-header"] { }
[data-streamdown="table-body"] { }
[data-streamdown="table-row"] { }
[data-streamdown="table-header-cell"] { }
[data-streamdown="table-cell"] { }
[data-streamdown="table-fullscreen"] { }
/* Other */
[data-streamdown="superscript"] { }
[data-streamdown="subscript"] { }Custom Components
Override any Markdown element via the components prop:
<Streamdown
components={{
h1: ({ children, ...props }) => (
<h1 className="text-4xl font-bold" {...props}>{children}</h1>
),
a: ({ children, href, ...props }) => (
<a href={href} className="text-blue-500 hover:underline" {...props}>
{children}
</a>
),
code: ({ children, className, ...props }) => {
const isInline = !className;
if (isInline) {
return <code className="bg-gray-100 rounded px-1" {...props}>{children}</code>;
}
return <code className={className} {...props}>{children}</code>;
},
}}
>
{markdown}
</Streamdown>Available elements: h1-h6, p, strong, em, ul, ol, li, a, code, pre, blockquote, table, thead, tbody, tr, th, td, img, hr, sup, sub, section
Scoped Styling
Use className prop for instance-specific styles:
<Streamdown className="docs-content">{markdown}</Streamdown>.docs-content [data-streamdown="heading-1"] {
font-family: 'Inter', sans-serif;
}
.docs-content [data-streamdown="code-block"] {
font-family: 'Fira Code', monospace;
}Theme Examples
Minimal Gray:
:root {
--primary: 0 0% 20%;
--muted: 0 0% 96%;
--border: 0 0% 90%;
--radius: 0.25rem;
}Vibrant Blue:
:root {
--primary: 217 91% 60%;
--muted: 214 100% 97%;
--border: 214 32% 91%;
--radius: 0.75rem;
}No Borders:
:root {
--border: transparent;
--muted: 0 0% 98%;
--radius: 0rem;
}Styling Priority
1. Custom Components — Complete control over rendering 2. CSS via `data-streamdown` selectors — Element-specific styling 3. CSS Variables — Global theme tokens
Related skills
How it compares
Pick streamdown over react-markdown when AI chat streams produce incomplete markdown blocks that need remend parsing, isAnimating carets, and rehype-harden sanitization.
FAQ
Why are Streamdown styles missing?
Add Tailwind v4 @source for node_modules/streamdown/dist/*.js or v3 content entry.
Why is the streaming caret invisible?
Both caret prop and isAnimating true are required on the streaming message.
What extra import does math need?
Import katex/dist/katex.min.css when using the @streamdown/math plugin.
Is Streamdown safe to install?
skills.sh reports 1 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.