Elements · Structured output
Web preview
Chrome for a sandboxed preview: a URL bar, reload, and open-in-new around a frame you isolate.
Installation
npx shadcn@latest add "@assistant-ui/elements-web-preview"First time? Set up a runtime
Runtime components read their state from an assistant-ui runtime. Add one to an existing project:
npx assistant-ui@latest initThen wrap your app in a runtime provider:
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";
export default function App() {
const runtime = useChatRuntime({
transport: new AssistantChatTransport({ api: "/api/chat" }),
});
return (
<AssistantRuntimeProvider runtime={runtime}>
{/* your components */}
</AssistantRuntimeProvider>
);
}The installation guide covers new projects, templates, and API routes.
npx shadcn@latest add "@assistant-ui/elements-web-preview"Props-driven: no runtime or provider required.
Web preview is chrome around a preview: a reload button, a URL bar, and an open-in-new-tab button around a frame you supply, rendered exactly as given with no isolation of its own. With a runtime the frame comes from rendering a tool's result through a sandboxing library; standalone you hand it an already-sandboxed frame directly.
Getting started
WebPreview never sandboxes its children itself, so pairing it with a tool that returns model-written markup means isolating that markup yourself. safe-content-frame is built for exactly this: it mounts an iframe on a separate, per-render origin so model-generated script cannot reach your page's cookies, storage, or DOM.
Render the tool call
"use client";
import { useEffect, useRef, useState } from "react";
import { SafeContentFrame, type RenderedFrame } from "safe-content-frame";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { WebPreview } from "@/components/assistant-ui/elements/web-preview";
const frame = new SafeContentFrame("my-app");
export const RenderPreviewToolUI: ToolCallMessagePartComponent<
Record<string, never>,
{ html: string }
> = ({ result }) => {
const containerRef = useRef<HTMLDivElement>(null);
const renderedRef = useRef<RenderedFrame>();
const [origin, setOrigin] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!result || !containerRef.current) return;
let cancelled = false;
frame.renderHtml(result.html, containerRef.current).then((rendered) => {
if (cancelled) return;
renderedRef.current = rendered;
setOrigin(new URL(rendered.origin).host);
rendered.fullyLoadedPromiseWithTimeout(5000).then(() => setLoading(false));
});
return () => {
cancelled = true;
renderedRef.current?.dispose();
};
}, [result]);
if (!result) return null;
return (
<WebPreview
origin={origin}
loading={loading}
onOpenExternal={() => window.open(`https://${origin}`, "_blank", "noopener,noreferrer")}
>
<div ref={containerRef} className="h-56" />
</WebPreview>
);
};Register the tool
import { defineToolkit } from "@assistant-ui/react";
import { z } from "zod";
import { RenderPreviewToolUI } from "@/components/assistant-ui/elements/render-preview-tool-ui";
export const toolkit = defineToolkit({
render_preview: {
type: "frontend",
description: "Render a self-contained HTML document as a live preview.",
parameters: z.object({ html: z.string() }),
execute: async ({ html }) => ({ html }),
render: RenderPreviewToolUI,
},
});import { AssistantRuntimeProvider, AuiConfig, Tools } from "@assistant-ui/react";
import { useChatRuntime } from "@assistant-ui/ai-sdk";
import { toolkit } from "./toolkit";
export function MyRuntimeProvider({ children }: { children: React.ReactNode }) {
const runtime = useChatRuntime();
const config = AuiConfig({ tools: Tools({ toolkit }) });
return (
<AssistantRuntimeProvider runtime={runtime} config={config}>
{children}
</AssistantRuntimeProvider>
);
}Install safe-content-frame separately; it is framework-agnostic and has no React dependency of its own. See Tool UI for backend-defined tools and approval gates.
Standalone, WebPreview is chrome only: build or reuse your own sandboxed frame and pass it as children.
Render an already-sandboxed frame
"use client";
import { useState } from "react";
import { WebPreview } from "@/components/assistant-ui/elements/web-preview";
export function Preview({ url }: { url: string }) {
const [loading, setLoading] = useState(true);
const [key, setKey] = useState(0);
return (
<WebPreview
origin={new URL(url).host}
loading={loading}
onReload={() => {
setLoading(true);
setKey((k) => k + 1);
}}
onOpenExternal={() => window.open(url, "_blank", "noopener,noreferrer")}
>
<iframe
key={key}
src={url}
sandbox="allow-scripts allow-same-origin"
className="h-56 w-full border-0"
onLoad={() => setLoading(false)}
/>
</WebPreview>
);
}Swap the frame when the url changes
Remount the iframe on a fresh key whenever url changes, the same way onReload does, so a stale frame is never left showing the previous address.
useEffect(() => {
setLoading(true);
setKey((k) => k + 1);
}, [url]);Anatomy
<div data-slot="web-preview">
<div>{/* reload button, origin field, open-in-new-tab button */}</div>
<div>{/* children, faded to invisible while loading */}</div>
{/* a shimmering "Loading preview" label, centered over the content area, only while loading */}
</div>loading hides children with invisible and opacity-0 rather than unmounting it, so a frame already in flight keeps loading in the background while the shimmer label covers it. The reload button's icon spins for as long as loading is true. onReload and onOpenExternal are both optional: the buttons render regardless and are simply no-ops without a handler.
Examples
Restyle the chrome
Both lanes take className on the root. The reload and open-in-new buttons read the shared ghostButton token, the origin field reads field, and the loading label reads ShimmerLabel, all from surfaces.tsx.
<WebPreview className="max-w-none" /* ... */ />Rendering something other than HTML
SafeContentFrame also renders raw bytes at any MIME type or a PDF; swap the render call and everything else in the tool UI stays the same.
await frame.renderRaw(result.svg, "image/svg+xml", containerRef.current);
// or
await frame.renderPdf(result.pdfBytes, containerRef.current);Previewing your own trusted content
WebPreview does not require an iframe at all. When the content is already trusted, same-origin, and static, pass it as plain markup instead:
<WebPreview origin="your-app.vercel.app/reports/42" loading={false}>
<img src="/reports/42/screenshot.png" alt="Report preview" className="w-full" />
</WebPreview>API reference
Tool-call render props
| Prop | Type | Description |
|---|---|---|
args | TArgs | Parsed arguments. Partial while the model is still streaming them. |
argsText | string | Raw JSON argument text streamed by the model. |
result | TResult | undefined | The tool's return value once it completes. undefined while running. |
status | ToolCallMessagePartStatus | status.type is "running", "requires-action", "complete", or "incomplete". |
toolName | string | Name of the tool the model called. |
toolCallId | string | Stable id for this invocation. |
isError | boolean | undefined | Whether result represents a tool execution error. |
Register the renderer on a toolkit entry's render field and attach the toolkit with Tools({ toolkit }). See Tool UI for the full render-prop surface, including addResult, human tools, and approval gates.
SafeContentFrame
| Member | Signature | Description |
|---|---|---|
renderHtml | (html: string, container: HTMLElement) => Promise<RenderedFrame> | Renders an HTML string into a sandboxed iframe appended to container. |
renderRaw | (content: Uint8Array | string, mimeType: string, container: HTMLElement) => Promise<RenderedFrame> | Renders any MIME type from a string or bytes. |
renderPdf | (content: Uint8Array, container: HTMLElement) => Promise<RenderedFrame> | Renders a PDF document. |
RenderedFrame.origin is the hashed, per-render origin the iframe loaded from; pass its host to WebPreview's origin prop. RenderedFrame.dispose() removes the iframe, and fullyLoadedPromiseWithTimeout(ms) resolves once the frame signals it has finished loading.
WebPreview
| Prop | Type | Default | Description |
|---|---|---|---|
origin | string | required | Shown in the URL bar. |
loading | boolean | required | Spins the reload icon and dims children behind a shimmering label. |
children | React.ReactNode | required | The preview content. Rendered exactly as given; not sandboxed by this element. |
onReload | () => void | Called when the reload button is pressed. | |
onOpenExternal | () => void | Called when the open-in-new-tab button is pressed. | |
className | string | Merged onto the root. |
All other div props are forwarded to the root.