Streamdown plugin
@uicast/streamdown puts generated UI inside a Markdown chat reply. The entries
sit in a fenced code block tagged uicast:
Here's this month's revenue at a glance:
```uicast
{"key":"root","component":"Card","seed":[{"set":"scopes.root.orders","expr":"listOrders()"}],"children":["stat"]}
{"key":"stat","component":"Stat","props":{"expr":"({ label: 'Revenue', value: scopes.root.orders.length })"}}
```
The stat is live — it recomputes from the database on every view.Streamdown renders Markdown as it streams and
lets a plugin claim a fence language. This plugin mounts uicast blocks as an
<EntriesRenderer> under your chat’s <RendererProvider>.
Every other fence stays highlighted code.
npm install @uicast/streamdown@beta streamdownStreamdown styles its output with Tailwind classes, so Tailwind has to scan its files. Add them to your CSS, with the path relative to that file, as Streamdown’s setup says:
@source "../node_modules/streamdown/dist/*.js";createFenceRenderer
import type { ComponentType } from "react";
import type { CustomRenderer } from "streamdown";
function createFenceRenderer(options?: FenceRendererOptions): CustomRenderer;
type FenceRendererOptions = {
/** Drawn above each block to switch it to its source. No toggle when omitted. */
sourceToggle?: ComponentType<SourceToggleProps>;
/** Render blocks in a server pass too. Default false. */
ssr?: boolean;
};
type SourceToggleProps = {
showSource: boolean;
onShowSourceChange: (showSource: boolean) => void;
};Implementations, the evaluator and fallback UI come from the
<RendererProvider> around the conversation. Each completed
line in the fence mounts at once; a partial last line waits for its newline.
import { Streamdown } from "streamdown";
import { RendererProvider } from "@uicast/react";
import { createFenceRenderer } from "@uicast/streamdown";
import { impls } from "@uicast/shadcn-catalog/all/impls";
import { Evaluator } from "@uicast/expr";
import { tools } from "./tools";
const evaluator = new Evaluator({ functions: tools });
const fenceRenderer = createFenceRenderer();
const plugins = { renderers: [fenceRenderer] };
export function Chat({ replies }: { replies: string[] }) {
return (
<RendererProvider implementations={impls} evaluator={evaluator}>
{replies.map((markdown, i) => (
<Streamdown key={i} plugins={plugins}>
{markdown}
</Streamdown>
))}
</RendererProvider>
);
}The conversation is one app. Every block under one <RendererProvider>
shares the root scope. The fence at the top of this page seeds
scopes.root.orders; a later reply writes the same path:
{"key":"root","component":"Button","seed":[{"set":"scopes.root.orders","expr":"listOrders()"}],"props":{"literal":{"text":"Refresh"}},"callbacks":{"onClick":[{"set":"scopes.root.orders","expr":"listOrders()"}]}}Click Refresh and stat in the earlier block updates. Both fences use the
key root: keys are per fence, only state is shared.
Every block seeds everything it reads. The renderer does not enforce this; the fence prompt tells the model to do it, even for a path an earlier block already seeded. Seeds are first-writer-wins, so the repeat writes nothing, and any block can be saved as a standalone page.
Blocks render after hydration: a server-rendered chat page shows each
block’s skeleton until the browser takes over, unless ssr is on.
sourceToggle
A development aid: your component, drawn as-is above each block, that switches
the block to its source. It gets showSource and onShowSourceChange, so any
toggle or switch fits:
import { createFenceRenderer, type SourceToggleProps } from "@uicast/streamdown";
import { Toggle } from "@/components/ui/toggle";
function SourceToggle({ showSource, onShowSourceChange }: SourceToggleProps) {
return (
<Toggle size="sm" pressed={showSource} onPressedChange={onShowSourceChange}>
Source
</Toggle>
);
}
const fenceRenderer = createFenceRenderer({ sourceToggle: SourceToggle });Each block has its own toggle. The source shows in Streamdown’s CodeBlock,
like other code in the chat. The block stays mounted while its source shows, so
switching back keeps its state.
ssr
By default the server HTML holds the block’s
skeleton, and nothing in it runs on the server.
The block renders once the page hydrates. With
ssr: true, the block renders in the server pass too:
- Its seeds run there and call their host functions on the server, so those functions must work there; see Two sets of tools.
- A streaming server sends each element’s skeleton first, then the element once its seed resolves; see What a server pass does.
- The browser runs every seed again, since the server does not send scope values.
Wrappers that forward plugins
Chat kits that wrap Streamdown, such as AI Elements’ response component, usually
spread your props over their default plugins, so passing plugins replaces
them. Pass the defaults along with the fence renderer:
const plugins = { cjk, code, math, mermaid, renderers: [fenceRenderer] };
<MessageResponse plugins={plugins}>{part.text}</MessageResponse>The prompt side
getFencePartialPrompt() from @uicast/streamdown/prompt teaches the model the
fence and the rules above. Partial replacement works
only within one fence. Compose it after the core partials; see
Assembling the prompt.
Where to go next
- Assembling the prompt — the system prompt that makes the model emit the fence.
- Renderer — the
<RendererProvider>the fences render under. - Streaming — the raw-JSONL path, without Markdown.