Custom renderers
Register custom renderers for arbitrary code fence languages.
The renderers field on PluginConfig lets you register custom React components for arbitrary code fence languages. Use this to render Vega-Lite charts, AntV infographics, D2 diagrams, PlantUML, or any other visualization — without forking Streamdown.
Custom renderers take priority over default code blocks. If a custom renderer matches a language, it renders instead of the default CodeBlock. You can even override mermaid by registering a renderer for the "mermaid" language.
Usage
Pass an array of { language, component } objects to plugins.renderers:
import { Streamdown } from "streamdown";
import type { CustomRendererProps } from "streamdown";
import { VegaLiteRenderer } from "./vega-lite-renderer";
export default function Chat() {
return (
<Streamdown
plugins={{
renderers: [
{ language: "vega-lite", component: VegaLiteRenderer },
],
}}
>
{markdown}
</Streamdown>
);
}Multiple languages
The language field accepts a string or an array of strings:
const renderers = [
{ language: ["vega", "vega-lite"], component: VegaLiteRenderer },
{ language: "infographic", component: InfographicRenderer },
{ language: "d2", component: D2Renderer },
];
<Streamdown plugins={{ renderers }}>{markdown}</Streamdown>Custom renderer props
Every custom renderer receives these props:
| Prop | Type | Description |
|---|---|---|
code | string | The raw text content inside the code fence |
language | string | The language identifier from the code fence |
isIncomplete | boolean | true while the code fence is still being streamed |
Reusing built-in components
Streamdown exports its internal code block components so your custom renderers can reuse them for consistent styling:
import { useEffect, useRef } from "react";
import type { CustomRendererProps } from "streamdown";
import { CodeBlockContainer, CodeBlockHeader } from "streamdown";
export const VegaLiteRenderer = ({
code,
language,
isIncomplete,
}: CustomRendererProps) => {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isIncomplete || !containerRef.current) {
return;
}
let cancelled = false;
const render = async () => {
const spec = JSON.parse(code);
const vegaEmbed = (await import("vega-embed")).default;
if (cancelled || !containerRef.current) {
return;
}
containerRef.current.innerHTML = "";
await vegaEmbed(containerRef.current, spec, {
actions: false,
renderer: "svg",
});
};
render();
return () => {
cancelled = true;
};
}, [code, isIncomplete]);
return (
<CodeBlockContainer isIncomplete={isIncomplete} language={language}>
<CodeBlockHeader language={language} />
{isIncomplete ? (
<div className="flex h-48 items-center justify-center rounded-md bg-muted">
<span className="text-muted-foreground text-sm">
Loading chart...
</span>
</div>
) : (
<div ref={containerRef} className="overflow-hidden rounded-md p-4" />
)}
</CodeBlockContainer>
);
};Exported components
| Component | Description |
|---|---|
CodeBlock | Full code block with syntax highlighting and controls |
CodeBlockContainer | Outer wrapper with border and styling |
CodeBlockHeader | Language label header |
CodeBlockCopyButton | Copy-to-clipboard button |
CodeBlockDownloadButton | Download button |
CodeBlockSkeleton | Loading skeleton placeholder |
Examples
Vega-Lite charts
Vega-Lite is a grammar for interactive graphics. Install vega, vega-lite, and vega-embed, then create a renderer that parses the JSON spec and calls vegaEmbed:
npm install vega vega-lite vega-embedThe renderer from the reusing built-in components section above is a complete Vega-Lite implementation. Once registered, your AI can output charts like:
```vega-lite
{
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
"width": "container",
"height": 200,
"data": {
"values": [
{"month": "Jan", "revenue": 28},
{"month": "Feb", "revenue": 55},
{"month": "Mar", "revenue": 43}
]
},
"mark": "bar",
"encoding": {
"x": {"field": "month", "type": "nominal"},
"y": {"field": "revenue", "type": "quantitative"}
}
}
```AntV Infographic
AntV Infographic renders rich infographic diagrams from a YAML-like DSL that supports streaming output. Install the package, then create a renderer:
npm install @antv/infographicimport { useEffect, useRef } from "react";
import type { CustomRendererProps } from "streamdown";
import { CodeBlockContainer, CodeBlockHeader } from "streamdown";
export const InfographicRenderer = ({
code,
language,
isIncomplete,
}: CustomRendererProps) => {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current) {
return;
}
let cancelled = false;
const render = async () => {
const { Infographic } = await import("@antv/infographic");
if (cancelled || !containerRef.current) {
return;
}
containerRef.current.innerHTML = "";
new Infographic({
container: containerRef.current,
text: code,
});
};
render();
return () => {
cancelled = true;
};
}, [code]);
return (
<CodeBlockContainer isIncomplete={isIncomplete} language={language}>
<CodeBlockHeader language={language} />
<div ref={containerRef} className="overflow-hidden rounded-md" />
</CodeBlockContainer>
);
};Register it alongside other renderers:
import { Streamdown } from "streamdown";
import { InfographicRenderer } from "./infographic-renderer";
import { VegaLiteRenderer } from "./vega-lite-renderer";
<Streamdown
plugins={{
renderers: [
{ language: ["vega", "vega-lite"], component: VegaLiteRenderer },
{ language: "infographic", component: InfographicRenderer },
],
}}
>
{markdown}
</Streamdown>Your AI can then output infographics using the AntV DSL:
```infographic
infographic list-row-horizontal-icon-arrow
data
title Product Development Lifecycle
desc Complete process from requirements to launch
items
- label Research
value 15
desc User interviews and competitive analysis
icon mdi/account-search
- label Design
value 42
desc Interaction prototype and visual design
icon mdi/palette
- label Development
value 65
desc Implementation and testing
icon mdi/code-tags
- label Launch
value 100
desc Official release and user feedback
icon mdi/rocket-launch
```Since AntV Infographic supports streaming natively, you can skip the isIncomplete guard and render progressively as the code fence streams in.
D2 diagrams
D2 is a declarative diagramming language. Since D2 compiles to SVG server-side, you can render it with an API call:
import { useEffect, useRef } from "react";
import type { CustomRendererProps } from "streamdown";
import { CodeBlockContainer, CodeBlockHeader } from "streamdown";
export const D2Renderer = ({
code,
language,
isIncomplete,
}: CustomRendererProps) => {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isIncomplete || !containerRef.current) {
return;
}
let cancelled = false;
const render = async () => {
const response = await fetch("/api/d2", {
method: "POST",
body: code,
});
if (cancelled || !containerRef.current) {
return;
}
const svg = await response.text();
containerRef.current.innerHTML = svg;
};
render();
return () => {
cancelled = true;
};
}, [code, isIncomplete]);
return (
<CodeBlockContainer isIncomplete={isIncomplete} language={language}>
<CodeBlockHeader language={language} />
{isIncomplete ? (
<div className="flex h-48 items-center justify-center rounded-md bg-muted">
<span className="text-muted-foreground text-sm">
Loading diagram...
</span>
</div>
) : (
<div ref={containerRef} className="overflow-hidden rounded-md p-4" />
)}
</CodeBlockContainer>
);
};Streaming considerations
During streaming, isIncomplete is true while the code fence is still being written. For most renderers, show a loading placeholder and wait for the complete spec:
const MyRenderer = ({ code, isIncomplete }: CustomRendererProps) => {
if (isIncomplete) {
return (
<div className="flex h-48 items-center justify-center rounded-md bg-muted">
<span className="text-muted-foreground text-sm">Loading...</span>
</div>
);
}
return <MyVisualization data={code} />;
};Some libraries like AntV Infographic support progressive rendering — in those cases, you can render on every update and skip the isIncomplete guard.
Combining with other plugins
Custom renderers work alongside all other plugins. The rendering priority is:
- Custom renderers (checked first)
- Mermaid plugin (if configured)
- Default code block with syntax highlighting
import { mermaid } from "@streamdown/mermaid";
import { code } from "@streamdown/code";
<Streamdown
plugins={{
code,
mermaid,
renderers: [
{ language: "vega-lite", component: VegaLiteRenderer },
{ language: "infographic", component: InfographicRenderer },
],
}}
>
{markdown}
</Streamdown>Type reference
interface CustomRendererProps {
code: string;
language: string;
isIncomplete: boolean;
}
interface CustomRenderer {
language: string | string[];
component: React.ComponentType<CustomRendererProps>;
}
interface PluginConfig {
// ...existing fields
renderers?: CustomRenderer[];
}