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:

chat.tsx
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:

PropTypeDescription
codestringThe raw text content inside the code fence
languagestringThe language identifier from the code fence
isIncompletebooleantrue 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:

vega-lite-renderer.tsx
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

ComponentDescription
CodeBlockFull code block with syntax highlighting and controls
CodeBlockContainerOuter wrapper with border and styling
CodeBlockHeaderLanguage label header
CodeBlockCopyButtonCopy-to-clipboard button
CodeBlockDownloadButtonDownload button
CodeBlockSkeletonLoading 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-embed

The 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/infographic
infographic-renderer.tsx
import { 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:

chat.tsx
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:

d2-renderer.tsx
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:

  1. Custom renderers (checked first)
  2. Mermaid plugin (if configured)
  3. 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[];
}