Skip to main content
The renderers field on PluginConfig lets you map code fence languages to custom React components. Use this to render Vega-Lite charts, D2 diagrams, infographics, or any visualization — without modifying Streamdown’s internals. Custom renderers take priority over the default code block. If a renderer matches the language, it renders instead of CodeBlock. You can even override the mermaid language by registering a renderer for it.

Basic usage

Pass an array of { language, component } objects to plugins.renderers:
import { Streamdown } from "streamdown";
import type { CustomRendererProps } from "streamdown";

const MyRenderer = ({ code, language, isIncomplete }: CustomRendererProps) => {
  if (isIncomplete) {
    return <div className="loading">Loading...</div>;
  }
  return <MyVisualization data={code} />;
};

export default function Chat() {
  return (
    <Streamdown
      plugins={{
        renderers: [
          { language: "my-lang", component: MyRenderer },
        ],
      }}
    >
      {markdown}
    </Streamdown>
  );
}

CustomRenderer interface

interface CustomRenderer {
  language: string | string[];
  component: React.ComponentType<CustomRendererProps>;
}
language accepts a single string or an array of strings. All listed language identifiers map to the same component.

CustomRendererProps

interface CustomRendererProps {
  code: string;
  isIncomplete: boolean;
  language: string;
  meta?: string;
}
code
string
required
The raw text content inside the code fence, exactly as it appears in the markdown source.
isIncomplete
boolean
required
true while the code fence is still being streamed (closing triple-backticks have not arrived yet). Use this to show a loading state and avoid attempting to parse incomplete data.
language
string
required
The language identifier from the opening fence, e.g. "vega-lite", "d2", "infographic".
meta
string
Everything after the language identifier on the opening fence line. For example, in ```rust {1} title="foo", meta is '{1} title="foo"'. Undefined when no metastring is present.

Multiple languages per renderer

const renderers = [
  { language: ["vega", "vega-lite"], component: VegaLiteRenderer },
  { language: "d2", component: D2Renderer },
];

<Streamdown plugins={{ renderers }}>
  {markdown}
</Streamdown>

Streaming considerations

During streaming, isIncomplete is true while the code fence is being written. For most renderers, show a placeholder and wait for the complete input before attempting to parse or render:
const MyRenderer = ({ code, isIncomplete }: CustomRendererProps) => {
  if (isIncomplete) {
    return (
      <div className="loading-placeholder">
        <span>Rendering...</span>
      </div>
    );
  }

  return <MyVisualization data={code} />;
};
Some libraries support progressive rendering (e.g., AntV Infographic). In those cases you can render on every update and skip the isIncomplete guard.

Reusing built-in components

Streamdown exports its internal code block components so your custom renderers can match the default styling:
import {
  CodeBlockContainer,
  CodeBlockHeader,
  CodeBlockCopyButton,
} from "streamdown";
import type { CustomRendererProps } 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">
          <span>Loading chart...</span>
        </div>
      ) : (
        <div ref={containerRef} className="overflow-hidden rounded-md p-4" />
      )}
    </CodeBlockContainer>
  );
};

Available exported components

ComponentDescription
CodeBlockFull code block with syntax highlighting and controls
CodeBlockContainerOuter wrapper with border and rounded corners
CodeBlockHeaderLanguage label header bar
CodeBlockCopyButtonStandalone copy-to-clipboard button
CodeBlockDownloadButtonStandalone download button
CodeBlockSkeletonLoading skeleton placeholder

How renderers integrate with the plugin pipeline

When Streamdown encounters a code fence, it checks renderers before falling back to the default code block:
  1. Custom renderers — first match by language wins
  2. Mermaid plugin — if plugins.mermaid is configured and the language is "mermaid"
  3. Default code block — with optional syntax highlighting from plugins.code
import { code } from "@streamdown/code";
import { mermaid } from "@streamdown/mermaid";

<Streamdown
  plugins={{
    code,
    mermaid,
    renderers: [
      { language: "vega-lite", component: VegaLiteRenderer },
      { language: "d2", component: D2Renderer },
    ],
  }}
>
  {markdown}
</Streamdown>

Type reference

interface CustomRendererProps {
  code: string;
  isIncomplete: boolean;
  language: string;
  meta?: string;
}

interface CustomRenderer {
  language: string | string[];
  component: React.ComponentType<CustomRendererProps>;
}

interface PluginConfig {
  cjk?: CjkPlugin;
  code?: CodeHighlighterPlugin;
  math?: MathPlugin;
  mermaid?: DiagramPlugin;
  renderers?: CustomRenderer[];
}

Build docs developers (and LLMs) love