
Streamdown
- 17 installs
- 5 repo stars
- Updated August 5, 2026
- bjornmelin/dev-skills
Streamdown is a Claude Code skill for Vercel's Streamdown library, a streaming-optimized react-markdown replacement for rendering AI-generated markdown in React chat UIs.
About
Streamdown is guidance for Vercel's Streamdown library, a react-markdown replacement built for streaming AI output. It shows how to render AI-generated markdown in React chat UIs while gracefully handling incomplete syntax during streaming. A developer uses it when building chat interfaces with the AI SDK useChat/streamText, or migrating from react-markdown. It covers Shiki code themes, KaTeX math, Mermaid diagrams, styling with Tailwind, and hardening output with rehype-harden.
- Drop-in react-markdown replacement that handles incomplete markdown during AI streaming via the remend preprocessor
- Configures Shiki code themes, KaTeX math, and Mermaid diagrams with copy/download controls
- Integrates with AI SDK useChat status for isAnimating and rehype-harden for safer AI output
Streamdown by the numbers
- 17 all-time installs (skills.sh)
- Ranked #1,582 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
streamdown capabilities & compatibility
- Capabilities
- render markdown · syntax highlighting · math rendering · diagram rendering · ai chat ui
- Works with
- vercel · openai · anthropic
- Use cases
- frontend · ui design
- IDEs
- vscode · cursor ide
What streamdown says it does
Streamdown is a drop-in react-markdown replacement designed for AI-powered streaming applications.
It handles incomplete markdown syntax gracefully using the remend preprocessor.
The `status` from useChat maps directly to Streamdown's `isAnimating`:
npx skills add https://github.com/bjornmelin/dev-skills --skill streamdownAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 17 |
|---|---|
| repo stars | ★ 5 |
| Last updated | August 5, 2026 |
| Repository | bjornmelin/dev-skills ↗ |
What it does
Render streaming AI-generated markdown in React chat UIs, handling incomplete markdown, code, math, and diagrams.
Who is it for?
Rendering incomplete, streaming AI markdown in React chat interfaces built on the AI SDK.
Skip if: Non-React apps or static markdown rendering where streaming is irrelevant.
When should I use this skill?
Rendering AI-generated markdown from useChat/streamText or migrating from react-markdown to streaming-friendly rendering.
What you get
A React chat UI that renders streaming markdown, code, math, and diagrams without breaking on partial syntax.
- React markdown rendering component wiring
- Shiki/KaTeX/Mermaid configuration
By the numbers
- 12 documented core props
Files
Streamdown - AI Streaming Markdown
Streamdown is a drop-in react-markdown replacement designed for AI-powered streaming applications. It handles incomplete markdown syntax gracefully using the remend preprocessor.
Quick Start
Installation
# Direct installation
pnpm add streamdown
# Or via AI Elements CLI (includes Response component)
pnpm dlx ai-elements@latest add messageTailwind Configuration
Tailwind v4 (globals.css):
@source "../node_modules/streamdown/dist/*.js";Tailwind v3 (tailwind.config.js):
module.exports = {
content: [
'./app/**/*.{js,ts,jsx,tsx}',
'./node_modules/streamdown/dist/*.js',
],
}Basic Chat Example
'use client';
import { useChat } from '@ai-sdk/react';
import { Streamdown } from 'streamdown';
export default function Chat() {
const { messages, sendMessage, status } = useChat();
return (
<>
{messages.map(message => (
<div key={message.id}>
{message.parts
.filter(part => part.type === 'text')
.map((part, index) => (
<Streamdown
key={index}
isAnimating={status === 'streaming'}
>
{part.text}
</Streamdown>
))}
</div>
))}
</>
);
}Core Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | string | required | Markdown content to render |
isAnimating | boolean | false | Disables interactive controls during streaming |
mode | `"streaming" \ | "static"` | "streaming" |
shikiTheme | [BundledTheme, BundledTheme] | ['github-light', 'github-dark'] | Light/dark syntax themes |
controls | `ControlsConfig \ | boolean` | true |
mermaid | MermaidOptions | {} | Diagram configuration |
components | object | {} | Custom element overrides |
className | string | "" | Container CSS class |
remarkPlugins | Pluggable[] | GFM, math, CJK | Markdown preprocessing |
rehypePlugins | Pluggable[] | raw, katex, harden | HTML processing |
parseIncompleteMarkdown | boolean | true | Enable remend preprocessor |
AI SDK Integration
Status-Based isAnimating
The status from useChat maps directly to Streamdown's isAnimating:
const { messages, status } = useChat();
// status: 'submitted' | 'streaming' | 'ready' | 'error'
<Streamdown isAnimating={status === 'streaming'}>
{content}
</Streamdown>Message Parts Pattern
AI SDK v6 uses message parts instead of content string:
{messages.map(message => (
<div key={message.id}>
{message.parts
.filter(part => part.type === 'text')
.map((part, index) => (
<Streamdown key={index} isAnimating={status === 'streaming'}>
{part.text}
</Streamdown>
))}
</div>
))}Memoized Response Component
Wrap Streamdown with React.memo for performance:
import { memo, ComponentProps } from 'react';
import { Streamdown } from 'streamdown';
export const Response = memo(
({ className, ...props }: ComponentProps<typeof Streamdown>) => (
<Streamdown
className={cn('prose dark:prose-invert max-w-none', className)}
{...props}
/>
)
);Configuration Examples
Shiki Themes
import type { BundledTheme } from 'shiki';
const themes: [BundledTheme, BundledTheme] = ['github-light', 'github-dark'];
<Streamdown shikiTheme={themes}>{content}</Streamdown>Controls
<Streamdown
controls={{
code: true, // Copy button on code blocks
table: true, // Download button on tables
mermaid: {
copy: true, // Copy diagram source
download: true, // Download as SVG
fullscreen: true, // Fullscreen view
panZoom: true, // Pan/zoom controls
},
}}
>
{content}
</Streamdown>Mermaid Diagrams
import type { MermaidConfig } from 'streamdown';
const mermaidConfig: MermaidConfig = {
theme: 'base',
themeVariables: {
fontFamily: 'Inter, sans-serif',
primaryColor: 'hsl(var(--primary))',
lineColor: 'hsl(var(--border))',
},
};
<Streamdown mermaid={{ config: mermaidConfig }}>{content}</Streamdown>Custom Error Component for Mermaid
import type { MermaidErrorComponentProps } from 'streamdown';
const MermaidError = ({ error, chart, retry }: MermaidErrorComponentProps) => (
<div className="p-4 border border-destructive rounded">
<p>Failed to render diagram</p>
<button onClick={retry}>Retry</button>
</div>
);
<Streamdown mermaid={{ errorComponent: MermaidError }}>{content}</Streamdown>Custom Components
Override any markdown element:
<Streamdown
components={{
h1: ({ children }) => <h1 className="text-4xl font-bold">{children}</h1>,
a: ({ href, children }) => (
<a href={href} className="text-primary underline">{children}</a>
),
code: ({ children, className }) => (
<code className={cn('bg-muted px-1 rounded', className)}>{children}</code>
),
}}
>
{content}
</Streamdown>Security Configuration
Restrict protocols for AI-generated content:
import { defaultRehypePlugins } from 'streamdown';
import { harden } from 'rehype-harden';
<Streamdown
rehypePlugins={[
defaultRehypePlugins.raw,
defaultRehypePlugins.katex,
[harden, {
allowedProtocols: ['http', 'https', 'mailto'],
allowedLinkPrefixes: ['https://your-domain.com'],
allowDataImages: false,
}],
]}
>
{content}
</Streamdown>Streaming vs Static Mode
| Mode | Use Case | Features |
|---|---|---|
streaming | AI chat responses | Block parsing, incomplete markdown handling, memoization |
static | Blog posts, docs | Simpler rendering, no streaming optimizations |
// Static mode for pre-rendered content
<Streamdown mode="static">{blogContent}</Streamdown>Built-in Features
- GFM: Tables, task lists, strikethrough, autolinks
- Math: KaTeX rendering with
$$...$$syntax - Code: Shiki syntax highlighting (200+ languages)
- Diagrams: Mermaid with interactive controls
- CJK: Proper emphasis handling for Chinese/Japanese/Korean
- Security: rehype-harden for link/image protocol restrictions
Reference Files
| Reference | Topics |
|---|---|
| api-reference.md | Complete props, types, plugins, data attributes |
| ai-sdk-integration.md | useChat patterns, server setup, message parts |
| styling-security.md | Tailwind, CSS variables, custom components, rehype-harden |
Common Patterns
Next.js Configuration
If you see bundling errors with Mermaid:
// next.config.js
module.exports = {
serverComponentsExternalPackages: ['langium', '@mermaid-js/parser'],
webpack: (config, { isServer }) => {
if (!isServer) {
config.resolve.alias = {
...config.resolve.alias,
'vscode-jsonrpc': false,
'langium': false,
};
}
return config;
},
};Shiki External Package
// next.config.js
{
transpilePackages: ['shiki'],
}Version Notes
- Streamdown: Works with React 18+ (optimized for React 19)
- AI SDK: Designed for v6 (status-based streaming state)
- Tailwind: Supports v3 and v4 configurations
AI SDK Integration
Complete guide for integrating Streamdown with AI SDK v6 for streaming chat applications.
Client Setup
Basic useChat Integration
'use client';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import { useState } from 'react';
import { Streamdown } from 'streamdown';
export default function Chat() {
const { messages, sendMessage, status } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
});
const [input, setInput] = useState('');
return (
<div className="flex flex-col h-screen">
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map(message => (
<div
key={message.id}
className={message.role === 'user' ? 'text-right' : 'text-left'}
>
<div className="inline-block max-w-2xl">
{message.parts
.filter(part => part.type === 'text')
.map((part, index) => (
<Streamdown
key={index}
isAnimating={status === 'streaming'}
>
{part.text}
</Streamdown>
))}
</div>
</div>
))}
</div>
<form
onSubmit={e => {
e.preventDefault();
if (input.trim()) {
sendMessage({ text: input });
setInput('');
}
}}
className="p-4 border-t"
>
<input
value={input}
onChange={e => setInput(e.target.value)}
disabled={status !== 'ready'}
placeholder="Type a message..."
className="w-full px-4 py-2 border rounded-lg"
/>
</form>
</div>
);
}Status Handling
Status Values
The status from useChat indicates the current state:
| Status | Description | isAnimating |
|---|---|---|
'submitted' | Request sent, waiting for response | true |
'streaming' | Receiving streamed response | true |
'ready' | Idle, ready for new messages | false |
'error' | An error occurred | false |
Status-Based Rendering
const { status, error, stop, reload } = useChat();
// Loading indicator
{(status === 'submitted' || status === 'streaming') && (
<div className="flex items-center gap-2">
{status === 'submitted' && <Spinner />}
<button onClick={stop}>Stop</button>
</div>
)}
// Error handling
{error && (
<div className="text-destructive">
<p>Error: {error.message}</p>
<button onClick={reload}>Retry</button>
</div>
)}
// Streamdown with status
<Streamdown isAnimating={status === 'streaming' || status === 'submitted'}>
{content}
</Streamdown>Message Parts Rendering
AI SDK v6 Message Structure
interface UIMessage {
id: string;
role: 'user' | 'assistant' | 'system';
parts: MessagePart[];
metadata?: Record<string, unknown>;
}
type MessagePart =
| { type: 'text'; text: string }
| { type: 'file'; filename: string; mediaType: string; url: string }
| { type: 'tool-invocation'; toolName: string; input: unknown; result?: unknown }
| { type: 'reasoning'; text: string }
| { type: 'source-url'; id: string; url: string; title?: string };Rendering Different Part Types
function MessageContent({ message, isStreaming }: { message: UIMessage; isStreaming: boolean }) {
return (
<>
{message.parts.map((part, index) => {
switch (part.type) {
case 'text':
return (
<Streamdown key={index} isAnimating={isStreaming}>
{part.text}
</Streamdown>
);
case 'reasoning':
return (
<details key={index} className="text-muted-foreground">
<summary>Reasoning</summary>
<Streamdown isAnimating={isStreaming}>{part.text}</Streamdown>
</details>
);
case 'source-url':
return (
<a key={index} href={part.url} className="text-primary underline">
{part.title ?? 'Source'}
</a>
);
case 'tool-invocation':
return (
<div key={index} className="border rounded p-2 bg-muted">
<span className="font-mono text-sm">{part.toolName}</span>
{part.result && <pre>{JSON.stringify(part.result, null, 2)}</pre>}
</div>
);
default:
return null;
}
})}
</>
);
}Memoization Pattern
Memoized Response Component
Wrap Streamdown in React.memo to prevent unnecessary re-renders:
import { memo, type ComponentProps } from 'react';
import { Streamdown } from 'streamdown';
import { cn } from '@/lib/utils';
type ResponseProps = ComponentProps<typeof Streamdown>;
export const Response = memo(
({
className,
controls = { code: true, table: true, mermaid: true },
mode = 'streaming',
shikiTheme = ['github-light', 'github-dark'],
...props
}: ResponseProps) => (
<Streamdown
className={cn(
'prose dark:prose-invert max-w-none',
'[&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
className
)}
controls={controls}
mode={mode}
shikiTheme={shikiTheme}
{...props}
/>
)
);
Response.displayName = 'Response';Usage with Memoized Component
import { Response } from '@/components/ai-elements/response';
{messages.map(message => (
<div key={message.id}>
{message.parts
.filter(part => part.type === 'text')
.map((part, index) => (
<Response key={index} isAnimating={status === 'streaming'}>
{part.text}
</Response>
))}
</div>
))}Server Setup
Route Handler (Next.js App Router)
// app/api/chat/route.ts
import { convertToModelMessages, streamText, UIMessage } from 'ai';
import { openai } from '@ai-sdk/openai';
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: openai('gpt-4o'),
system: 'You are a helpful assistant.',
messages: convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse();
}With Tools
import { convertToModelMessages, streamText, UIMessage, tool } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: openai('gpt-4o'),
system: 'You are a helpful assistant with access to tools.',
messages: convertToModelMessages(messages),
tools: {
weather: tool({
description: 'Get weather for a location',
inputSchema: z.object({ location: z.string() }),
execute: async ({ location }) => {
return { temperature: 72, condition: 'sunny', location };
},
}),
},
});
return result.toUIMessageStreamResponse();
}With Reasoning and Sources
const result = streamText({
model: anthropic('claude-sonnet-4'),
messages: convertToModelMessages(messages),
providerOptions: {
anthropic: {
thinking: { type: 'enabled', budgetTokens: 10000 },
},
},
});
return result.toUIMessageStreamResponse({
sendReasoning: true, // Include reasoning parts
sendSources: true, // Include source parts (Perplexity, Google)
});Complete Example
Client Component
// app/chat/page.tsx
'use client';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import { useState } from 'react';
import { Response } from '@/components/ai-elements/response';
export default function ChatPage() {
const { messages, sendMessage, status, error, stop, reload } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
});
const [input, setInput] = useState('');
const isLoading = status === 'submitted' || status === 'streaming';
return (
<div className="flex flex-col h-screen max-w-3xl mx-auto">
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{messages.map(message => (
<div
key={message.id}
className={cn(
'flex',
message.role === 'user' ? 'justify-end' : 'justify-start'
)}
>
<div
className={cn(
'max-w-[80%] rounded-lg px-4 py-2',
message.role === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-muted'
)}
>
{message.parts
.filter(part => part.type === 'text')
.map((part, index) => (
<Response
key={index}
isAnimating={isLoading && message.role === 'assistant'}
>
{part.text}
</Response>
))}
</div>
</div>
))}
{isLoading && status === 'submitted' && (
<div className="flex justify-start">
<div className="bg-muted rounded-lg px-4 py-2">
<span className="animate-pulse">Thinking...</span>
</div>
</div>
)}
{error && (
<div className="flex justify-center">
<div className="bg-destructive/10 text-destructive rounded-lg px-4 py-2">
<p>{error.message}</p>
<button onClick={reload} className="underline mt-2">
Retry
</button>
</div>
</div>
)}
</div>
<div className="border-t p-4">
<form
onSubmit={e => {
e.preventDefault();
if (input.trim() && !isLoading) {
sendMessage({ text: input });
setInput('');
}
}}
className="flex gap-2"
>
<input
value={input}
onChange={e => setInput(e.target.value)}
disabled={isLoading}
placeholder="Type a message..."
className="flex-1 px-4 py-2 border rounded-lg"
/>
{isLoading ? (
<button type="button" onClick={stop} className="px-4 py-2 border rounded-lg">
Stop
</button>
) : (
<button type="submit" disabled={!input.trim()} className="px-4 py-2 bg-primary text-primary-foreground rounded-lg">
Send
</button>
)}
</form>
</div>
</div>
);
}Server Route
// app/api/chat/route.ts
import { convertToModelMessages, streamText, UIMessage } from 'ai';
import { openai } from '@ai-sdk/openai';
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: openai('gpt-4o'),
system: `You are a helpful assistant. Format your responses using markdown:
- Use **bold** for emphasis
- Use code blocks for code
- Use lists for multiple items
- Use tables when comparing things`,
messages: convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse({
onError: error => {
if (error instanceof Error) return error.message;
return 'An error occurred';
},
});
}Performance Tips
1. Use React.memo: Wrap your Response component to prevent re-renders 2. Key properly: Use stable keys for message parts (index is fine within parts array) 3. Throttle updates: Use experimental_throttle on useChat for high-frequency updates 4. Static mode for completed: Consider switching to mode="static" after streaming completes 5. Memoize configurations: Define shikiTheme, controls, and mermaid config outside render
// Good: Config outside component
const shikiTheme: [BundledTheme, BundledTheme] = ['github-light', 'github-dark'];
const controls = { code: true, table: true, mermaid: true };
function Chat() {
// ...
return <Streamdown shikiTheme={shikiTheme} controls={controls}>{content}</Streamdown>;
}
// Bad: Config inside render (creates new objects each render)
function Chat() {
return <Streamdown shikiTheme={['github-light', 'github-dark']}>{content}</Streamdown>;
}Streamdown API Reference
Complete reference for Streamdown props, types, and plugin configurations.
StreamdownProps Interface
interface StreamdownProps {
// Content
children: string; // Markdown content to render
// Streaming
isAnimating?: boolean; // Disable controls during streaming (default: false)
mode?: 'streaming' | 'static'; // Rendering mode (default: 'streaming')
parseIncompleteMarkdown?: boolean; // Enable remend preprocessor (default: true)
// Theming
shikiTheme?: [BundledTheme, BundledTheme]; // [lightTheme, darkTheme]
className?: string; // Container CSS class
// Features
controls?: ControlsConfig | boolean; // Interactive button visibility
mermaid?: MermaidOptions; // Diagram configuration
components?: Partial<Components>; // Custom element overrides
// Plugins
remarkPlugins?: Pluggable[]; // Markdown preprocessing
rehypePlugins?: Pluggable[]; // HTML processing
// Advanced
BlockComponent?: React.ComponentType<BlockProps>;
parseMarkdownIntoBlocksFn?: (markdown: string) => string[];
}ControlsConfig Interface
type ControlsConfig = {
code?: boolean; // Copy button on code blocks (default: true)
table?: boolean; // Download button on tables (default: true)
mermaid?: MermaidControls | boolean;
};
type MermaidControls = {
copy?: boolean; // Copy diagram source (default: true)
download?: boolean; // Download as SVG (default: true)
fullscreen?: boolean; // Fullscreen view (default: true)
panZoom?: boolean; // Pan/zoom controls (default: true)
};Examples:
// Enable all controls
<Streamdown controls={true}>{content}</Streamdown>
// Disable all controls
<Streamdown controls={false}>{content}</Streamdown>
// Granular control
<Streamdown
controls={{
code: true,
table: false,
mermaid: { fullscreen: true, download: true, copy: false, panZoom: false },
}}
>
{content}
</Streamdown>MermaidOptions Interface
interface MermaidOptions {
config?: MermaidConfig;
errorComponent?: React.ComponentType<MermaidErrorComponentProps>;
}
interface MermaidErrorComponentProps {
error: string; // Error message from Mermaid
chart: string; // Original diagram source
retry: () => void; // Function to retry rendering
}
// MermaidConfig is the official Mermaid configuration type
interface MermaidConfig {
theme?: 'default' | 'dark' | 'forest' | 'neutral' | 'base';
themeVariables?: {
fontFamily?: string;
fontSize?: string;
primaryColor?: string;
primaryTextColor?: string;
primaryBorderColor?: string;
lineColor?: string;
secondaryColor?: string;
tertiaryColor?: string;
// ... many more theme variables
};
flowchart?: {
nodeSpacing?: number;
rankSpacing?: number;
curve?: 'basis' | 'linear' | 'cardinal';
};
sequence?: {
actorMargin?: number;
boxMargin?: number;
boxTextMargin?: number;
};
// ... other diagram-specific configs
}Default Plugins
Default Remark Plugins
Access via import { defaultRemarkPlugins } from 'streamdown':
| Plugin | Purpose | Configuration |
|---|---|---|
gfm | GitHub Flavored Markdown | Tables, task lists, strikethrough, autolinks |
math | Math syntax support | { singleDollarTextMath: false } |
cjkFriendly | CJK text emphasis | Handles ideographic punctuation |
cjkFriendlyGfmStrikethrough | CJK strikethrough | Proper strikethrough with CJK chars |
import { defaultRemarkPlugins } from 'streamdown';
// Access individual plugins
const plugins = [
defaultRemarkPlugins.gfm,
defaultRemarkPlugins.math,
defaultRemarkPlugins.cjkFriendly,
defaultRemarkPlugins.cjkFriendlyGfmStrikethrough,
];
// Or use all defaults
const allPlugins = Object.values(defaultRemarkPlugins);Default Rehype Plugins
Access via import { defaultRehypePlugins } from 'streamdown':
| Plugin | Purpose | Configuration |
|---|---|---|
raw | HTML support | Preserves raw HTML in markdown |
katex | Math rendering | { errorColor: 'var(--color-muted-foreground)' } |
harden | Security hardening | Link/image protocol restrictions |
import { defaultRehypePlugins } from 'streamdown';
// Access individual plugins
const plugins = [
defaultRehypePlugins.raw,
defaultRehypePlugins.katex,
defaultRehypePlugins.harden,
];Harden Plugin Options
interface HardenOptions {
allowedImagePrefixes?: string[]; // Default: ['*'] (all allowed)
allowedLinkPrefixes?: string[]; // Default: ['*'] (all allowed)
allowedProtocols?: string[]; // Default: ['*'] (all allowed)
defaultOrigin?: string; // Origin for relative URLs
allowDataImages?: boolean; // Default: true
}Custom Components
Override any markdown element via the components prop:
interface Components {
// Headings
h1: React.ComponentType<HeadingProps>;
h2: React.ComponentType<HeadingProps>;
h3: React.ComponentType<HeadingProps>;
h4: React.ComponentType<HeadingProps>;
h5: React.ComponentType<HeadingProps>;
h6: React.ComponentType<HeadingProps>;
// Text
p: React.ComponentType<ParagraphProps>;
strong: React.ComponentType<StrongProps>;
em: React.ComponentType<EmphasisProps>;
// Links & Code
a: React.ComponentType<AnchorProps>;
code: React.ComponentType<CodeProps>;
pre: React.ComponentType<PreProps>;
// Lists
ul: React.ComponentType<ListProps>;
ol: React.ComponentType<ListProps>;
li: React.ComponentType<ListItemProps>;
// Blocks
blockquote: React.ComponentType<BlockquoteProps>;
hr: React.ComponentType<HRProps>;
// Tables
table: React.ComponentType<TableProps>;
thead: React.ComponentType<TheadProps>;
tbody: React.ComponentType<TbodyProps>;
tr: React.ComponentType<TRProps>;
th: React.ComponentType<THProps>;
td: React.ComponentType<TDProps>;
// Media
img: React.ComponentType<ImageProps>;
// Other
sup: React.ComponentType<SupProps>;
sub: React.ComponentType<SubProps>;
section: React.ComponentType<SectionProps>;
}Example: Custom Link Component
<Streamdown
components={{
a: ({ href, children, ...props }) => {
const isExternal = href?.startsWith('http');
return (
<a
href={href}
target={isExternal ? '_blank' : undefined}
rel={isExternal ? 'noopener noreferrer' : undefined}
className="text-primary hover:underline"
{...props}
>
{children}
{isExternal && ' ↗'}
</a>
);
},
}}
>
{content}
</Streamdown>Data Attributes for CSS
Streamdown adds data-streamdown attributes for CSS targeting:
| Selector | Element |
|---|---|
[data-streamdown="heading-1"] | h1 |
[data-streamdown="heading-2"] | h2 |
[data-streamdown="heading-3"] | h3 |
[data-streamdown="heading-4"] | h4 |
[data-streamdown="heading-5"] | h5 |
[data-streamdown="heading-6"] | h6 |
[data-streamdown="strong"] | strong/bold |
[data-streamdown="link"] | anchor links |
[data-streamdown="inline-code"] | inline code |
[data-streamdown="ordered-list"] | ol |
[data-streamdown="unordered-list"] | ul |
[data-streamdown="list-item"] | li |
[data-streamdown="blockquote"] | blockquote |
[data-streamdown="horizontal-rule"] | hr |
[data-streamdown="code-block"] | code block container |
[data-streamdown="mermaid-block"] | mermaid diagram container |
[data-streamdown="table-wrapper"] | table container |
[data-streamdown="table"] | table |
[data-streamdown="table-header"] | thead |
[data-streamdown="table-body"] | tbody |
[data-streamdown="table-row"] | tr |
[data-streamdown="table-header-cell"] | th |
[data-streamdown="table-cell"] | td |
[data-streamdown="superscript"] | sup |
[data-streamdown="subscript"] | sub |
Example CSS:
/* Custom code block styling */
[data-streamdown="code-block"] {
border: 1px solid hsl(var(--border));
border-radius: var(--radius);
background-color: hsl(var(--muted));
}
/* Custom blockquote */
[data-streamdown="blockquote"] {
border-left: 3px solid hsl(var(--primary));
padding-left: 1rem;
font-style: italic;
}Shiki Themes
Common theme pairs for shikiTheme:
import type { BundledTheme } from 'shiki';
// Popular combinations
const themes: Record<string, [BundledTheme, BundledTheme]> = {
github: ['github-light', 'github-dark'],
vitesse: ['vitesse-light', 'vitesse-dark'],
nord: ['nord', 'nord'],
dracula: ['min-light', 'dracula'],
monokai: ['min-light', 'monokai'],
oneDark: ['one-light', 'one-dark-pro'],
};See Shiki Themes for the complete list.
Math Syntax
Streamdown uses double $ delimiters (single $ disabled to avoid currency conflicts):
Inline math: $E = mc^2$
Block math:
$$
\int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi}
$$Mermaid Diagram Types
Supported diagram types:
- Flowchart (
graph TD/LR/BT/RL) - Sequence diagram (
sequenceDiagram) - State diagram (
stateDiagram-v2) - Class diagram (
classDiagram) - Entity relationship (
erDiagram) - Gantt chart (
gantt) - Pie chart (
pie) - Git graph (
gitGraph)
````markdown
graph TD
A[Start] --> B{Decision}
B -->|Yes| C[Success]
B -->|No| D[Try Again]````
Styling and Security
Complete guide for styling Streamdown components and securing AI-generated content.
Tailwind CSS Setup
Tailwind v4 (CSS-based config)
Add to your globals.css:
@import "tailwindcss";
/* Include Streamdown component styles */
@source "../node_modules/streamdown/dist/*.js";Tailwind v3 (JS config)
Add to tailwind.config.js:
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./app/**/*.{js,ts,jsx,tsx}',
'./components/**/*.{js,ts,jsx,tsx}',
'./node_modules/streamdown/dist/*.js',
],
// ...
};CSS Variables
Streamdown uses CSS variables for theming. Define these in your globals:
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--border: 240 5.9% 90%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--destructive: 0 84.2% 60.2%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--border: 240 3.7% 15.9%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--destructive: 0 62.8% 30.6%;
}Using Variables in Mermaid
import type { MermaidConfig } from 'streamdown';
const mermaidConfig: MermaidConfig = {
theme: 'base',
themeVariables: {
fontFamily: 'Inter, system-ui, sans-serif',
primaryColor: 'hsl(var(--muted))',
primaryTextColor: 'hsl(var(--foreground))',
primaryBorderColor: 'hsl(var(--border))',
lineColor: 'hsl(var(--border))',
secondaryColor: 'hsl(var(--background))',
tertiaryColor: 'hsl(var(--accent))',
},
};Data Attributes for CSS
Streamdown adds data-streamdown attributes for precise CSS targeting:
Complete Selector Reference
| Selector | Element | Common Styles |
|---|---|---|
[data-streamdown="heading-1"] | h1 | text-3xl font-bold |
[data-streamdown="heading-2"] | h2 | text-2xl font-semibold |
[data-streamdown="heading-3"] | h3 | text-xl font-semibold |
[data-streamdown="heading-4"] | h4 | text-lg font-medium |
[data-streamdown="heading-5"] | h5 | text-base font-medium |
[data-streamdown="heading-6"] | h6 | text-sm font-medium |
[data-streamdown="strong"] | strong | font-semibold |
[data-streamdown="link"] | a | text-primary underline |
[data-streamdown="inline-code"] | code (inline) | bg-muted px-1 rounded |
[data-streamdown="ordered-list"] | ol | list-decimal pl-6 |
[data-streamdown="unordered-list"] | ul | list-disc pl-6 |
[data-streamdown="list-item"] | li | my-1 |
[data-streamdown="blockquote"] | blockquote | border-l-4 pl-4 italic |
[data-streamdown="horizontal-rule"] | hr | border-t my-4 |
[data-streamdown="code-block"] | pre wrapper | rounded-lg overflow-hidden |
[data-streamdown="mermaid-block"] | mermaid wrapper | my-4 |
[data-streamdown="table-wrapper"] | table container | overflow-x-auto |
[data-streamdown="table"] | table | w-full border-collapse |
[data-streamdown="table-header"] | thead | bg-muted |
[data-streamdown="table-body"] | tbody | — |
[data-streamdown="table-row"] | tr | border-b |
[data-streamdown="table-header-cell"] | th | px-4 py-2 text-left font-medium |
[data-streamdown="table-cell"] | td | px-4 py-2 |
[data-streamdown="superscript"] | sup | — |
[data-streamdown="subscript"] | sub | — |
Example Stylesheet
/* Custom code block styling */
[data-streamdown="code-block"] {
border: 1px solid hsl(var(--border));
border-radius: var(--radius);
background-color: hsl(var(--muted));
}
/* Custom blockquote */
[data-streamdown="blockquote"] {
border-left: 3px solid hsl(var(--primary));
padding-left: 1rem;
font-style: italic;
color: hsl(var(--muted-foreground));
}
/* External link indicator */
[data-streamdown="link"][href^="http"]::after {
content: " ↗";
font-size: 0.75em;
}
/* Striped tables */
[data-streamdown="table-body"] [data-streamdown="table-row"]:nth-child(even) {
background-color: hsl(var(--muted) / 0.5);
}
/* Code block header */
[data-streamdown="code-block"]::before {
content: attr(data-language);
display: block;
padding: 0.5rem 1rem;
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
border-bottom: 1px solid hsl(var(--border));
}Custom Components
Override markdown elements via the components prop:
Link with External Detection
<Streamdown
components={{
a: ({ href, children, ...props }) => {
const isExternal = href?.startsWith('http');
return (
<a
href={href}
target={isExternal ? '_blank' : undefined}
rel={isExternal ? 'noopener noreferrer' : undefined}
className="text-primary hover:underline"
{...props}
>
{children}
{isExternal && <span className="text-xs ml-1">↗</span>}
</a>
);
},
}}
>
{content}
</Streamdown>Custom Code Block
<Streamdown
components={{
pre: ({ children, ...props }) => (
<div className="relative group">
<pre
className="overflow-x-auto p-4 rounded-lg bg-muted"
{...props}
>
{children}
</pre>
</div>
),
code: ({ children, className, ...props }) => {
// Check if it's inline code (no className) vs code block
const isInline = !className;
if (isInline) {
return (
<code className="bg-muted px-1.5 py-0.5 rounded text-sm" {...props}>
{children}
</code>
);
}
return <code className={className} {...props}>{children}</code>;
},
}}
>
{content}
</Streamdown>Custom Headings with Anchors
import { slugify } from '@/lib/utils';
<Streamdown
components={{
h2: ({ children, ...props }) => {
const text = typeof children === 'string' ? children : '';
const id = slugify(text);
return (
<h2 id={id} className="group flex items-center gap-2" {...props}>
{children}
<a href={`#${id}`} className="opacity-0 group-hover:opacity-100">
#
</a>
</h2>
);
},
}}
>
{content}
</Streamdown>Security with rehype-harden
Default Configuration
Streamdown includes rehype-harden by default. Access via:
import { defaultRehypePlugins } from 'streamdown';
// Default harden plugin is at:
defaultRehypePlugins.hardenHardenOptions Interface
interface HardenOptions {
allowedImagePrefixes?: string[]; // Default: ['*'] (all allowed)
allowedLinkPrefixes?: string[]; // Default: ['*'] (all allowed)
allowedProtocols?: string[]; // Default: ['*'] (all allowed)
defaultOrigin?: string; // Origin for relative URLs
allowDataImages?: boolean; // Default: true
}Restricting Protocols
For AI-generated content, restrict to safe protocols:
import { defaultRehypePlugins } from 'streamdown';
import { harden } from 'rehype-harden';
<Streamdown
rehypePlugins={[
defaultRehypePlugins.raw,
defaultRehypePlugins.katex,
[harden, {
allowedProtocols: ['http', 'https', 'mailto'],
// Blocks: javascript:, data:, file:, etc.
}],
]}
>
{content}
</Streamdown>Domain Allowlisting
Restrict links and images to specific domains:
<Streamdown
rehypePlugins={[
defaultRehypePlugins.raw,
defaultRehypePlugins.katex,
[harden, {
allowedProtocols: ['https'],
allowedLinkPrefixes: [
'https://your-domain.com',
'https://docs.your-domain.com',
'https://github.com',
],
allowedImagePrefixes: [
'https://your-cdn.com',
'https://images.your-domain.com',
],
allowDataImages: false, // Block data: URIs for images
}],
]}
>
{content}
</Streamdown>Complete Security Configuration
Production-ready configuration for AI-generated content:
// streamdown-config.ts
import { defaultRehypePlugins, defaultRemarkPlugins } from 'streamdown';
import type { StreamdownProps } from 'streamdown';
// Extract harden plugin for customization
const hardenRaw = defaultRehypePlugins.harden;
const hardenFn = Array.isArray(hardenRaw) ? hardenRaw[0] : hardenRaw;
const hardenDefaults = Array.isArray(hardenRaw) && hardenRaw[1]
? hardenRaw[1]
: {};
export const secureRehypePlugins: StreamdownProps['rehypePlugins'] = [
defaultRehypePlugins.raw,
defaultRehypePlugins.katex,
[hardenFn, {
...hardenDefaults,
allowedProtocols: ['http', 'https', 'mailto'],
allowDataImages: false,
}],
];
export const secureRemarkPlugins: StreamdownProps['remarkPlugins'] =
Object.values(defaultRemarkPlugins);Security Best Practices
1. Never Trust AI Output
AI models can generate malicious content. Always:
- Use rehype-harden with restricted protocols
- Disable
javascript:anddata:protocols - Consider domain allowlisting for production
2. Sanitize Before Rendering
// Bad: Direct rendering
<Streamdown>{aiResponse}</Streamdown>
// Good: With security plugins
<Streamdown rehypePlugins={secureRehypePlugins}>
{aiResponse}
</Streamdown>3. CSP Headers
Complement client-side sanitization with Content Security Policy:
// next.config.js
const securityHeaders = [
{
key: 'Content-Security-Policy',
value: `
default-src 'self';
script-src 'self' 'unsafe-eval' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' https: data:;
font-src 'self';
`.replace(/\n/g, ''),
},
];4. Image Handling
For AI-generated image URLs:
<Streamdown
components={{
img: ({ src, alt, ...props }) => {
// Validate image source
const isAllowed = src?.startsWith('https://your-cdn.com');
if (!isAllowed) {
return <span className="text-muted-foreground">[Image blocked]</span>;
}
return (
<img
src={src}
alt={alt ?? 'AI generated image'}
loading="lazy"
{...props}
/>
);
},
}}
>
{content}
</Streamdown>5. Link Click Handling
Intercept and validate link clicks:
<Streamdown
components={{
a: ({ href, children, ...props }) => {
const handleClick = (e: React.MouseEvent) => {
// Log or validate before navigation
if (href?.includes('suspicious-pattern')) {
e.preventDefault();
console.warn('Blocked suspicious link:', href);
return;
}
};
return (
<a
href={href}
onClick={handleClick}
rel="noopener noreferrer"
{...props}
>
{children}
</a>
);
},
}}
>
{content}
</Streamdown>Prose Styling with Tailwind Typography
Combine Streamdown with @tailwindcss/typography:
<Streamdown
className="prose dark:prose-invert max-w-none
prose-headings:font-semibold
prose-a:text-primary
prose-code:bg-muted prose-code:px-1 prose-code:rounded
prose-pre:bg-transparent prose-pre:p-0"
>
{content}
</Streamdown>Dark Mode Support
CSS Variables Approach
/* Automatic dark mode via CSS variables */
.dark [data-streamdown="code-block"] {
background-color: hsl(var(--muted));
}
.dark [data-streamdown="blockquote"] {
border-color: hsl(var(--border));
}Shiki Theme Switching
Streamdown automatically uses the second theme in dark mode:
// [lightTheme, darkTheme]
<Streamdown shikiTheme={['github-light', 'github-dark']}>
{content}
</Streamdown>Theme pairs that work well:
['github-light', 'github-dark']- Default, familiar['vitesse-light', 'vitesse-dark']- Minimal, clean['one-light', 'one-dark-pro']- VS Code style['min-light', 'dracula']- High contrast
Related skills
FAQ
What does Streamdown replace?
It is a drop-in react-markdown replacement designed for AI-powered streaming applications.
How does it handle incomplete markdown?
It uses the remend preprocessor, enabled via parseIncompleteMarkdown, to handle incomplete syntax during streaming.