Browse all patterns
Generative UI Dashboard
The model assembles a dashboard from a controlled widget registry — streamed props, an unsupported-request fallback, and widget clicks that re-enter the chat.
generative-dashboard.tsx399 lines5 componentsReact + Tailwind + shadcn/ui
npx shadcn@latest add @scrimui/generative-dashboardAgent promptClaude Code · Cursor · any agent
Add the Generative UI Dashboard pattern from the Scrim UI registry to this project — a complete screen, not a single component.
Generative UI Dashboard — The model assembles a dashboard from a controlled widget registry — streamed props, an unsupported-request fallback, and widget clicks that re-enter the chat.
## 1. Install
```bash
npx shadcn@latest add @scrimui/generative-dashboard
```
This writes the screen to `components/blocks/generative-dashboard.tsx` and pulls in the components it is built from, each landing at `components/ui/`. Everything is plain React + Tailwind with no runtime dependencies. The imports in the block already point at those paths, so it compiles as installed.
## 2. What it is made of
- `generative-ui` — Generative UI
- `tool-call` — Tool Call
- `artifact-preview` — Artifact Preview
- `error-message` — Error Message
- `prompt-input` — Prompt Input
Each is a separate file you can edit or replace on its own; the block is the arrangement, not a monolith.
## 3. Rules this layout depends on
Keep these when adapting the screen — they are the reasons it works, and they are easy to break while restyling:
- Give the model a registry, not a canvas: metric card, bar chart, data table, report — every widget attributed to the tool call that made it.
- Stream props into a skeleton with the widget's final shape — the layout is known before the data, so nothing resizes on arrival.
- Degrade unsupported requests to prose plus the raw data — refused, not improvised.
- Treat widget interaction as conversation: clicking a bar sends the filter back into the chat as a message the model answers.
- Isolate widget failure to the widget — one card errors with its own Retry, the rest of the dashboard never blinks.
## 4. Do not do these
- Letting the model emit arbitrary markup — one hallucinated iframe and your dashboard is a security review.
- A spinner where the widget will be: the reader watches the card resize instead of watching data arrive.
- Silently swapping an unsupported request for a different chart — the user asked for a scatter and got a bar chart they'll misread.
- Widget clicks that mutate the canvas without entering the conversation — undoable, unauditable, invisible to the model.
- A render error in one widget tearing down the whole dashboard, or worse, the whole chat.
The demo content in the file — messages, file names, model names — is placeholder. Replace it with this project's real data and wire the handlers to real state rather than shipping the stubs.
Reference: https://scrimui.dev/patterns/generative-dashboardInstalls the screen and every component it is built from, and carries the layout rules from this page so an agent does not restyle them away.
Live Preview
Analytics copilot
Registry: metric card · bar chart · data table · report
Ask for a number, get a dashboard
Try: “Revenue by region for Q3”, then “Show a 3D scatter of reps”, then “Break it down by week”.
The canvas is empty — widgets land here as the model renders them.
Built from these components
Render a React component from a tool result instead of text — a skeleton in the widget's own shape, and a text fallback when the client has no renderer.
How to show a function call in chat — the arguments sent, the result returned, run status, and an expand toggle.
The side panel for generated output — preview/code toggle, copy, download, version switching, and streaming, error and stale states.
What to show when a generation fails — a plain-English reason, a retry button, and a countdown for rate limits.
The message input at the heart of an AI chat app — file attachments, model picker, tool toggles, voice, and a send button that turns into stop.
Pattern code
This file composes the components above. Copy each component from its page, then this pattern file wires them together.
"use client";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import * as React from "react";
import { GenerativeUi } from "../../generative-ui/generative-ui";
import { ToolCall } from "../../tool-call/tool-call";
import { ArtifactPreview } from "../../artifact-preview/artifact-preview";
import { ErrorMessage } from "../../error-message/error-message";
import { PromptInput } from "../../prompt-input/prompt-input";
/**
* A dashboard the model assembles from a controlled widget registry.
*
* What this pattern exists to show:
*
* 1. **The model picks from allowed components, never arbitrary UI.** Metric
* card, bar chart, data table, report — that is the whole registry, and
* every widget is attributed to the tool call that produced it.
* 2. **Props stream into a shaped skeleton.** The layout is known before the
* data is, so nothing resizes when the numbers arrive.
* 3. **An unsupported request degrades to prose, not a crash.** The model
* asked for a 3D scatter; the registry said no, and the raw result is
* still behind the Data toggle.
* 4. **Widget interaction is conversation.** Clicking a bar sends the filter
* back into the chat as a message — the model answers it there.
* 5. **One broken widget is one broken card.** The table failed on a bad
* field name; the other widgets never blinked, and Retry fixes just it.
*/
/* ------------------------------------------------------------------ */
/* Mock data */
/* ------------------------------------------------------------------ */
const REGIONS = [
{ label: "West", value: 412 },
{ label: "East", value: 368 },
{ label: "North", value: 295 },
{ label: "South", value: 231 },
];
const METRICS = [
{ label: "Q3 revenue", value: "$1.31M", delta: "+11% vs Q2" },
{ label: "Deals closed", value: "184", delta: "+9%" },
{ label: "Avg deal size", value: "$7.1k", delta: "+2%" },
];
const WEEKS = [
{ week: "Jul 6", revenue: "$148k", deals: 21 },
{ week: "Jul 13", revenue: "$162k", deals: 24 },
{ week: "Jul 20", revenue: "$141k", deals: 19 },
{ week: "Jul 27", revenue: "$176k", deals: 26 },
];
const REPORT_MD = `# Q3 revenue summary
West leads at $412k (+8% vs Q2), East close behind at $368k.
North recovered after a slow July; South is flat.
Recommended: shift two East reps to the South pipeline review.`;
const SCATTER_JSON = `{
"requested": "scatter_3d",
"registry": ["metric_card", "bar_chart", "data_table", "report"],
"result": { "points": 184, "axes": ["deal_size", "cycle_days", "region"] }
}`;
/* ------------------------------------------------------------------ */
/* Registry widgets — the only components the model may render */
/* ------------------------------------------------------------------ */
function MetricCard({ label, value, delta }: { label: string; value: string; delta: string }) {
return (
<div className="px-4 py-3">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-0.5 text-xl font-semibold tabular-nums text-foreground">{value}</p>
<p className="text-xs font-medium text-teal-600 dark:text-teal-400">{delta}</p>
</div>
);
}
function BarChart({ onPick }: { onPick?: (region: string) => void }) {
const max = Math.max(...REGIONS.map((r) => r.value));
return (
<div className="space-y-2 px-4 py-3">
<p className="text-xs font-medium text-muted-foreground">Revenue by region — click a bar to filter</p>
{REGIONS.map((r) => (
<Button variant="ghost" size="sm"
key={r.label}
type="button"
onClick={() => onPick?.(r.label)}
className="h-auto min-h-8 whitespace-normal justify-start group flex w-full items-center gap-2 text-left"
aria-label={`Filter to ${r.label}, $${r.value}k`}
>
<span className="w-10 text-xs text-muted-foreground">{r.label}</span>
<span className="h-4 rounded bg-muted transition-colors group-hover:bg-muted" style={{ width: `${(r.value / max) * 70}%` }} />
<span className="text-xs tabular-nums text-muted-foreground">${r.value}k</span>
</Button>
))}
</div>
);
}
function DataTable() {
return (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-xs text-muted-foreground">
<th className="px-4 py-2 font-medium">Week</th>
<th className="px-4 py-2 font-medium">Revenue</th>
<th className="px-4 py-2 font-medium">Deals</th>
</tr>
</thead>
<tbody>
{WEEKS.map((w) => (
<tr key={w.week} className="border-b border-border last:border-0">
<td className="px-4 py-2 text-foreground">{w.week}</td>
<td className="px-4 py-2 tabular-nums text-foreground">{w.revenue}</td>
<td className="px-4 py-2 tabular-nums text-foreground">{w.deals}</td>
</tr>
))}
</tbody>
</table>
);
}
function ReportBody() {
return (
<div className="space-y-2 px-4 py-3 text-sm leading-6 text-foreground">
<p>West leads at <strong className="text-foreground">$412k</strong> (+8% vs Q2), East close behind at $368k.</p>
<p>North recovered after a slow July; South is flat. Recommended: shift two East reps to the South pipeline review.</p>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
type ChatItem =
| { id: string; kind: "user" | "assistant"; text: string }
| { id: string; kind: "tool"; name: string; input: string; output?: string; status: "running" | "success"; duration?: string };
type Widget = {
id: string;
tool: string;
type: "metric" | "chart" | "table" | "report" | "unsupported";
state: "streaming" | "ready" | "error";
metricIndex?: number;
};
/* ------------------------------------------------------------------ */
/* Pattern */
/* ------------------------------------------------------------------ */
export function GenerativeDashboardPattern() {
const [chat, setChat] = React.useState<ChatItem[]>([]);
const [widgets, setWidgets] = React.useState<Widget[]>([]);
const [submits, setSubmits] = React.useState(0);
const [busy, setBusy] = React.useState(false);
const [filtered, setFiltered] = React.useState<string | null>(null);
const [retrying, setRetrying] = React.useState(false);
const timers = React.useRef<number[]>([]);
React.useEffect(() => {
const t = timers.current;
return () => t.forEach(clearTimeout);
}, []);
function later(ms: number, fn: () => void) {
timers.current.push(window.setTimeout(fn, ms));
}
function push(...items: ChatItem[]) {
setChat((c) => [...c, ...items]);
}
function setWidgetState(id: string, state: Widget["state"]) {
setWidgets((ws) => ws.map((w) => (w.id === id ? { ...w, state } : w)));
}
function submit(text: string) {
if (busy) return;
const n = submits + 1;
setSubmits(n);
setBusy(true);
push({ id: `u${n}`, kind: "user", text });
if (n === 1) {
push({ id: "t1", kind: "tool", name: "query_metrics", input: '{ metric: "revenue", by: "region", quarter: "Q3" }', status: "running" });
later(1000, () => {
setChat((c) =>
c.map((item) =>
item.id === "t1" && item.kind === "tool"
? { ...item, status: "success", output: '{ regions: 4, total: "$1.31M" }', duration: "0.8s" }
: item,
),
);
push({ id: "a1", kind: "assistant", text: "Q3 revenue is $1.31M, up 11% on Q2. West leads — I put the breakdown on the canvas, plus a written summary you can copy out." });
setWidgets([
{ id: "m0", tool: "query_metrics", type: "metric", state: "streaming", metricIndex: 0 },
{ id: "m1", tool: "query_metrics", type: "metric", state: "streaming", metricIndex: 1 },
{ id: "m2", tool: "query_metrics", type: "metric", state: "streaming", metricIndex: 2 },
{ id: "c1", tool: "query_metrics", type: "chart", state: "streaming" },
{ id: "r1", tool: "write_report", type: "report", state: "streaming" },
]);
});
later(1700, () => ["m0", "m1", "m2"].forEach((id) => setWidgetState(id, "ready")));
later(2200, () => {
setWidgetState("c1", "ready");
setWidgetState("r1", "ready");
setBusy(false);
});
} else if (n === 2) {
later(700, () => {
push({
id: "a2",
kind: "assistant",
text: "I can't render that one: a 3D scatter isn't in this dashboard's registry, so I won't fake it. The raw result is below — the deal-size vs cycle-days view works as a table if you want it.",
});
setWidgets((ws) => [...ws, { id: "x1", tool: "render_widget", type: "unsupported", state: "ready" }]);
setBusy(false);
});
} else if (n === 3) {
setWidgets((ws) => [...ws, { id: "tb1", tool: "weekly_breakdown", type: "table", state: "streaming" }]);
later(1100, () => {
setWidgetState("tb1", "error");
push({ id: "a3", kind: "assistant", text: "The weekly table failed to render — the tool returned the data under a field the widget didn't expect. Retry it; nothing else on the canvas is affected." });
setBusy(false);
});
} else {
later(600, () => {
push({ id: `a${n}`, kind: "assistant", text: "Nothing new to add — the canvas already covers revenue by region, the weekly breakdown and the summary report. Ask for a different cut if you want another widget." });
setBusy(false);
});
}
}
function retryTable() {
setRetrying(true);
setWidgetState("tb1", "streaming");
later(1400, () => {
setWidgetState("tb1", "ready");
setRetrying(false);
push({ id: "a3r", kind: "assistant", text: "Fixed — the weekly breakdown is on the canvas now. July 27 was the strongest week at $176k across 26 deals." });
});
}
function pickRegion(region: string) {
if (filtered === region || busy) return;
setFiltered(region);
push({ id: `uf-${region}`, kind: "user", text: `Filter: ${region}` });
later(700, () => {
push({
id: `af-${region}`,
kind: "assistant",
text: `Filtered to ${region}: $412k revenue, up 8% vs Q2, with the shortest sales cycle of any region at 23 days.`,
});
});
}
return (
<Card className="flex-row gap-0 py-0 flex h-[640px] overflow-hidden rounded-xl border border-border bg-card">
{/* Conversation drives the canvas */}
<div className="flex w-[320px] shrink-0 flex-col border-r border-border max-md:w-full">
<div className="border-b border-border px-4 py-3">
<p className="text-sm font-semibold text-foreground">Analytics copilot</p>
<p className="text-xs text-muted-foreground">Registry: metric card · bar chart · data table · report</p>
</div>
<div className="flex-1 space-y-3 overflow-y-auto px-3 py-3">
{chat.length === 0 && (
<div className="rounded-xl border border-dashed border-border px-3 py-5 text-center">
<p className="text-sm font-medium text-foreground">Ask for a number, get a dashboard</p>
<p className="mt-1 text-xs text-muted-foreground">
Try: “Revenue by region for Q3”, then “Show a 3D scatter of reps”, then “Break it down by week”.
</p>
</div>
)}
{chat.map((item) =>
item.kind === "user" ? (
<div key={item.id} className="flex justify-end">
<p className="max-w-[90%] rounded-xl rounded-br-md bg-primary px-3.5 py-2 text-sm leading-6 text-primary-foreground">
{item.text}
</p>
</div>
) : item.kind === "tool" ? (
<ToolCall key={item.id} name={item.name} input={item.input} output={item.output} status={item.status} duration={item.duration} />
) : (
<p key={item.id} className="rounded-xl border border-border bg-card px-3.5 py-2 text-sm leading-6 text-foreground">
{item.text}
</p>
),
)}
</div>
<div className="border-t border-border p-3">
<PromptInput onSubmit={submit} placeholder="Ask for a metric…" loading={busy} />
</div>
</div>
{/* Canvas — only registry widgets may appear here */}
<div className="flex-1 overflow-y-auto bg-muted p-4 max-md:hidden">
{widgets.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">The canvas is empty — widgets land here as the model renders them.</p>
</div>
) : (
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3">
{widgets.map((w) =>
w.type === "metric" ? (
<GenerativeUi
key={w.id}
tool={w.tool}
state={w.state === "ready" ? "ready" : "streaming"}
skeleton={
<div className="space-y-2 px-4 py-3">
<div className="h-3 w-20 animate-pulse rounded bg-muted" />
<div className="h-6 w-24 animate-pulse rounded bg-muted" />
<div className="h-3 w-14 animate-pulse rounded bg-muted" />
</div>
}
>
<MetricCard {...METRICS[w.metricIndex ?? 0]} />
</GenerativeUi>
) : w.type === "chart" ? (
<div key={w.id} className="col-span-2">
<GenerativeUi
tool={w.tool}
state={w.state === "ready" ? "ready" : "streaming"}
skeleton={
<div className="space-y-2.5 px-4 py-3">
{[70, 62, 50, 38].map((pct) => (
<div key={pct} className="h-4 animate-pulse rounded bg-muted" style={{ width: `${pct}%` }} />
))}
</div>
}
>
<BarChart onPick={pickRegion} />
</GenerativeUi>
</div>
) : w.type === "table" ? (
<div key={w.id} className="col-span-2">
<GenerativeUi
tool={w.tool}
state={w.state === "streaming" ? "streaming" : "ready"}
skeleton={
<div className="space-y-2 px-4 py-3">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="h-4 w-full animate-pulse rounded bg-muted" />
))}
</div>
}
>
{w.state === "error" ? (
<div className="p-3">
<ErrorMessage
title="Widget failed to render"
message="rows is undefined — the tool returned weekly data under `weeks`, not `rows`."
onRetry={retryTable}
retrying={retrying}
/>
</div>
) : (
<DataTable />
)}
</GenerativeUi>
</div>
) : w.type === "report" ? (
<div key={w.id} className="col-span-2 lg:col-span-3">
<ArtifactPreview
title="Q3 revenue summary"
type="document"
status={w.state === "ready" ? "ready" : "streaming"}
preview={<ReportBody />}
code={REPORT_MD}
language="Markdown"
/>
</div>
) : (
<GenerativeUi
key={w.id}
tool={w.tool}
state="unsupported"
fallback={
<span>
The registry has no <code className="rounded bg-muted px-1">scatter_3d</code> widget — the model
asked for one anyway. Refused, not improvised: the raw result is under Data.
</span>
}
data={SCATTER_JSON}
/>
),
)}
</div>
)}
</div>
</Card>
);
}
When to use it
- Give the model a registry, not a canvas: metric card, bar chart, data table, report — every widget attributed to the tool call that made it.
- Stream props into a skeleton with the widget's final shape — the layout is known before the data, so nothing resizes on arrival.
- Degrade unsupported requests to prose plus the raw data — refused, not improvised.
- Treat widget interaction as conversation: clicking a bar sends the filter back into the chat as a message the model answers.
- Isolate widget failure to the widget — one card errors with its own Retry, the rest of the dashboard never blinks.
What breaks in production
- Letting the model emit arbitrary markup — one hallucinated iframe and your dashboard is a security review.
- A spinner where the widget will be: the reader watches the card resize instead of watching data arrive.
- Silently swapping an unsupported request for a different chart — the user asked for a scatter and got a bar chart they'll misread.
- Widget clicks that mutate the canvas without entering the conversation — undoable, unauditable, invisible to the model.
- A render error in one widget tearing down the whole dashboard, or worse, the whole chat.
More Patterns
The canonical chat interface — sidebar, streaming messages, prompt input with model selector, and sources.
AI Research AssistantA research flow that shows search tool calls, reasoning, sources and a cited final answer.
AI Coding AgentA coding run with agent status, tool calls, diffs and a human-in-the-loop approval gate.
AI Voice AssistantA voice-first conversation — live waveform states, a recording input, a spoken transcript and a typed fallback.
Model & Memory PreferencesA preferences screen that picks the model, reasoning level and tools, and manages persistent memory.
Artifact WorkspaceChat on the left, generated output on the right — artifacts open from the answer, stream, version, and fail without breaking the conversation.
Document Q&A WorkspaceAsk your own documents — upload and parse, cited answers with inspectable passages, an honest not-found state, and a visible context budget.
Structured Extraction & ReviewUpload a document, watch fields fill in, then review the flagged ones — per-field confidence, corrections that keep the original, export earned.
Image Generation StudioA one-screen generation studio — prompt composer and model picker beside a feed of queued, staged, blocked and ready image results with variants.
Multi-agent Ops ConsoleWatch a fleet of agents at once — parallel statuses, an inspectable handoff, a waiting approval, a failed child run, and per-run plus fleet cost.
Customer Support CopilotGrounded reply drafts with citations, honest low-confidence answers, inline corrections, an approval gate on refunds, and a rating row on every draft.