"use client";
import * as React from "react";
import { Tabs } from "radix-ui";
import { ArrowDownTrayIcon, ArrowUpIcon, ChevronLeftIcon, ChevronRightIcon, CodeBracketIcon, EyeIcon, SparklesIcon, StopIcon } from "@heroicons-animated/react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { CodeBlock, CodeBlockContent, CodeBlockCopyButton, CodeBlockGroup, CodeBlockHeader, CodeBlockShiki } from "@/components/pandacoderz-ui/code-block";
import { Message, MessageAvatar, MessageContent, MessageMarkdown, MessageStack } from "@/components/pandacoderz-ui/message";
import { PromptInput, PromptInputAction, PromptInputActionGroup, PromptInputActions, PromptInputTextarea } from "@/components/pandacoderz-ui/prompt-input";
import { Suggestion, SuggestionList, Suggestions } from "@/components/pandacoderz-ui/suggestions";
import { TextShimmer } from "@/components/pandacoderz-ui/text-shimmer";
import { Thread, ThreadContent, ThreadScrollToBottom } from "@/components/pandacoderz-ui/thread";
import { sleep, uid } from "@/lib/mock-stream";
import { useStreamText } from "@/lib/use-stream-text";
type Version = { id: number; label: string; html: string };
type Turn = { id: string; role: "user" | "assistant"; text: string };
const base = (accent: string, bg: string, fg: string, dark = false) => `<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
:root { color-scheme: ${dark ? "dark" : "light"}; }
body { margin: 0; font: 15px/1.5 Inter, system-ui, sans-serif; background: ${bg}; color: ${fg}; display: grid; place-items: center; min-height: 100vh; }
.card { width: min(360px, 90vw); padding: 28px; border-radius: 20px; background: ${dark ? "#1e293b" : "#fff"}; box-shadow: 0 10px 40px -12px rgba(0,0,0,.2); }
.badge { display: inline-block; font-size: 11px; padding: 4px 10px; border-radius: 999px; background: ${accent}22; color: ${accent}; font-weight: 600; letter-spacing: .02em; }
h1 { font-size: 24px; margin: 14px 0 6px; letter-spacing: -.02em; }
p { margin: 0 0 18px; opacity: .75; }
.price { font-size: 40px; font-weight: 700; letter-spacing: -.03em; }
.price small { font-size: 14px; opacity: .6; font-weight: 500; }
button { width: 100%; padding: 12px; border: 0; border-radius: 12px; background: ${accent}; color: white; font-weight: 600; font-size: 14px; cursor: pointer; }
ul { padding: 0; margin: 16px 0 0; list-style: none; font-size: 13px; }
li { padding: 6px 0; border-top: 1px solid ${dark ? "#334155" : "#eef2f7"}; }
</style>
</head>
<body>
<div class="card">
<span class="badge">PRO</span>
<h1>Everything you need</h1>
<p>For teams shipping AI features to production.</p>
<div class="price">$49 <small>/ month</small></div>
<button>Start free trial</button>
<ul><li>Unlimited projects</li><li>Priority support</li><li>SSO and audit log</li></ul>
</div>
</body>
</html>`;
const versions: Version[] = [
{ id: 1, label: "Pricing card", html: base("#7c3aed", "#f8fafc", "#0f172a") },
{ id: 2, label: "Dark mode", html: base("#a78bfa", "#0f172a", "#f8fafc", true) },
{ id: 3, label: "Emerald accent", html: base("#059669", "#f8fafc", "#0f172a") },
];
const replies = [
"Here's a pricing card with a violet accent, a PRO badge, and a three-item feature list. Want a different palette or layout?",
"Switched the canvas to a dark theme. I kept the accent but lifted it a shade so it still passes contrast on the navy background.",
"Changed the accent to emerald. The gradient badge and the CTA both pick it up automatically since they share one variable.",
];
export type ArtifactCanvasProps = { className?: string };
export default function ArtifactCanvas({ className }: ArtifactCanvasProps) {
const [turns, setTurns] = React.useState<Turn[]>([]);
const [draft, setDraft] = React.useState("");
const [history, setHistory] = React.useState<Version[]>([versions[0]]);
const [current, setCurrent] = React.useState(0);
const [tab, setTab] = React.useState("preview");
const [pendingId, setPendingId] = React.useState<string | null>(null);
const { text, isStreaming, start, stop } = useStreamText(14);
const version = history[current];
const submit = React.useCallback(
async (value: string) => {
const v = value.trim();
if (!v || isStreaming) return;
setDraft("");
const userTurn: Turn = { id: uid("u"), role: "user", text: v };
const assistantId = uid("a");
setTurns((prev) => [...prev, userTurn]);
setPendingId(assistantId);
const nextIndex = history.length % versions.length;
const reply = history.length === 0 ? replies[0] : replies[nextIndex] ?? replies[0];
await sleep(600);
const nextVersion = { ...versions[nextIndex], id: history.length + 1 };
setHistory((prev) => [...prev, nextVersion]);
setCurrent(history.length);
setTab("preview");
await start(reply, () => {
setTurns((prev) => [...prev, { id: assistantId, role: "assistant", text: reply }]);
setPendingId(null);
});
},
[history, isStreaming, start],
);
React.useEffect(() => {
if (turns.length === 0) {
setTurns([{ id: "seed", role: "assistant", text: replies[0] }]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div data-slot="artifact-canvas" className={cn("grid h-full min-h-0 w-full overflow-hidden rounded-3xl border bg-background shadow-xs md:grid-cols-[minmax(0,22rem)_minmax(0,1fr)] lg:grid-cols-[minmax(0,26rem)_minmax(0,1fr)]", className)}>
<section className="flex min-h-0 flex-col border-b md:border-r md:border-b-0">
<header className="flex h-12 shrink-0 items-center gap-2 border-b px-4 text-sm font-medium">
<span className="flex size-6 items-center justify-center rounded-md bg-brand text-primary-foreground"><SparklesIcon size={14} className="flex" /></span>
Canvas chat
</header>
<Thread className="min-h-0 flex-1">
<ThreadContent className="gap-4 p-4">
{turns.map((t) =>
t.role === "user" ? (
<Message key={t.id} from="user"><MessageStack><MessageContent>{t.text}</MessageContent></MessageStack></Message>
) : (
<Message key={t.id} from="assistant" className="max-w-none">
<MessageAvatar fallback={<SparklesIcon size={14} className="flex" />} />
<MessageStack><MessageContent><MessageMarkdown>{t.text}</MessageMarkdown></MessageContent></MessageStack>
</Message>
),
)}
{pendingId ? (
<Message from="assistant" className="max-w-none">
<MessageAvatar fallback={<SparklesIcon size={14} className="flex" />} />
<MessageStack>
<MessageContent>{text ? <MessageMarkdown isAnimating>{text}</MessageMarkdown> : <TextShimmer className="text-sm text-muted-foreground" invertLight>Updating canvas…</TextShimmer>}</MessageContent>
</MessageStack>
</Message>
) : null}
</ThreadContent>
<ThreadScrollToBottom />
</Thread>
<div className="shrink-0 p-3 pt-0">
{turns.length <= 1 ? (
<Suggestions onSelect={submit} className="mb-2">
<SuggestionList>{["Make it dark", "Use an emerald accent"].map((s) => <Suggestion key={s} variant="outline" className="h-7 text-xs">{s}</Suggestion>)}</SuggestionList>
</Suggestions>
) : null}
<PromptInput onSubmit={submit}>
<PromptInputTextarea value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="Describe a change…" />
<PromptInputActions>
<PromptInputActionGroup><span className="px-2 text-xs text-muted-foreground">Editing v{version.id}</span></PromptInputActionGroup>
<PromptInputActionGroup>
{isStreaming ? (
<PromptInputAction asChild tooltip="Stop"><Button size="icon-sm" variant="secondary" className="rounded-full" aria-label="Stop" onClick={stop}><StopIcon size={16} className="flex" /></Button></PromptInputAction>
) : (
<PromptInputAction asChild tooltip={{ content: "Send", shortcut: "↵" }}><Button size="icon-sm" className="rounded-full" aria-label="Send" disabled={!draft.trim()} onClick={() => submit(draft)}><ArrowUpIcon size={16} className="flex" /></Button></PromptInputAction>
)}
</PromptInputActionGroup>
</PromptInputActions>
</PromptInput>
</div>
</section>
<section className="flex min-h-0 flex-col bg-surface">
<Tabs.Root value={tab} onValueChange={setTab} className="flex min-h-0 flex-1 flex-col">
<header className="flex h-12 shrink-0 items-center justify-between gap-2 border-b bg-background px-3">
<Tabs.List className="flex items-center gap-1 rounded-lg bg-muted p-1 text-xs">
<Tabs.Trigger value="preview" className="flex h-7 items-center gap-1.5 rounded-md px-2.5 font-medium text-muted-foreground transition-colors data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-xs"><EyeIcon size={12} className="flex" /> Preview</Tabs.Trigger>
<Tabs.Trigger value="code" className="flex h-7 items-center gap-1.5 rounded-md px-2.5 font-medium text-muted-foreground transition-colors data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-xs"><CodeBracketIcon size={12} className="flex" /> Code</Tabs.Trigger>
</Tabs.List>
<div className="flex items-center gap-1">
<div className="flex items-center gap-0.5 rounded-full border px-1 text-xs">
<Button size="icon-xs" variant="ghost" className="rounded-full" aria-label="Previous version" disabled={current === 0} onClick={() => setCurrent((c) => c - 1)}><ChevronLeftIcon size={12} className="flex" /></Button>
<span className="px-1 font-mono tabular-nums">v{version.id}<span className="text-muted-foreground">/{history.length}</span></span>
<Button size="icon-xs" variant="ghost" className="rounded-full" aria-label="Next version" disabled={current === history.length - 1} onClick={() => setCurrent((c) => c + 1)}><ChevronRightIcon size={12} className="flex" /></Button>
</div>
<Button size="icon-sm" variant="ghost" className="rounded-full text-muted-foreground" aria-label="Download"><ArrowDownTrayIcon size={16} className="flex" /></Button>
</div>
</header>
<Tabs.Content value="preview" className="min-h-0 flex-1 p-3 data-[state=inactive]:hidden">
<iframe key={version.id} title={`Artifact v${version.id}`} srcDoc={version.html} sandbox="" className="size-full rounded-2xl border bg-white" />
</Tabs.Content>
<Tabs.Content value="code" className="min-h-0 flex-1 overflow-hidden p-3 data-[state=inactive]:hidden">
<CodeBlock className="h-full">
<CodeBlockHeader>
<CodeBlockGroup><CodeBracketIcon size={14} className="flex" /><span className="font-mono text-xs">index.html · v{version.id} · {version.label}</span></CodeBlockGroup>
<CodeBlockCopyButton />
</CodeBlockHeader>
<CodeBlockContent className="max-h-none h-[calc(100%-2.375rem)]">
<CodeBlockShiki code={version.html} language="html" lineNumbers />
</CodeBlockContent>
</CodeBlock>
</Tabs.Content>
</Tabs.Root>
</section>
</div>
);
}/**
* Helpers for scripted streaming in demos. Blocks use these to fake a model
* response token by token without touching the network. Swap them for a real
* fetch in production; the UI code does not change.
*/
export function sleep(ms: number, signal?: AbortSignal) {
return new Promise<void>((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason);
const id = setTimeout(resolve, ms);
signal?.addEventListener(
"abort",
() => {
clearTimeout(id);
reject(signal.reason);
},
{ once: true },
);
});
}
/** Split text into word-ish chunks so streaming looks natural. */
export function chunk(text: string): string[] {
return text.match(/\S+\s*|\s+/g) ?? [text];
}
export function jitter(base: number) {
return base + Math.random() * base;
}
/** Yield a string piece by piece with a small random delay between pieces. */
export async function* streamText(
text: string,
signal?: AbortSignal,
delayMs = 14,
): AsyncGenerator<string> {
for (const piece of chunk(text)) {
if (signal?.aborted) return;
yield piece;
await sleep(jitter(delayMs), signal);
}
}
let counter = 0;
export const uid = (prefix = "id") => `${prefix}-${Date.now().toString(36)}-${++counter}`;"use client";
import * as React from "react";
import { streamText } from "./mock-stream";
/**
* Drive a piece of text onto the screen as if it were streaming from a model.
* `start(text)` clears and streams; `stop()` freezes what has arrived.
*/
export function useStreamText(delayMs = 14) {
const [text, setText] = React.useState("");
const [isStreaming, setIsStreaming] = React.useState(false);
const abortRef = React.useRef<AbortController | null>(null);
const stop = React.useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setIsStreaming(false);
}, []);
const start = React.useCallback(
async (full: string, onDone?: () => void) => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setText("");
setIsStreaming(true);
try {
for await (const piece of streamText(full, controller.signal, delayMs)) {
setText((prev) => prev + piece);
}
if (!controller.signal.aborted) onDone?.();
} catch {
/* aborted */
} finally {
if (abortRef.current === controller) {
abortRef.current = null;
setIsStreaming(false);
}
}
},
[delayMs],
);
const reset = React.useCallback(() => {
stop();
setText("");
}, [stop]);
React.useEffect(() => () => abortRef.current?.abort(), []);
return { text, isStreaming, start, stop, reset, setText };
}What’s inside
- Chat on the left using Thread, Message, and Prompt Input.
- Preview renders each version’s HTML in a sandboxed iframe.
- Code tab shows the source with Shiki highlighting and a copy button.
- Version switcher steps through history as the assistant makes changes.
Installation
npx shadcn@latest add https://ui.spencerwueste.com/r/artifact-canvas.jsonpnpm dlx shadcn@latest add https://ui.spencerwueste.com/r/artifact-canvas.jsonyarn dlx shadcn@latest add https://ui.spencerwueste.com/r/artifact-canvas.jsonbunx --bun shadcn@latest add https://ui.spencerwueste.com/r/artifact-canvas.jsonInstall the dependencies:
npm install radix-ui @heroicons-animated/react motionAdd the shadcn primitives it builds on:
npx shadcn@latest add buttonCopy the source into your project:
"use client";
import * as React from "react";
import { Tabs } from "radix-ui";
import { ArrowDownTrayIcon, ArrowUpIcon, ChevronLeftIcon, ChevronRightIcon, CodeBracketIcon, EyeIcon, SparklesIcon, StopIcon } from "@heroicons-animated/react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { CodeBlock, CodeBlockContent, CodeBlockCopyButton, CodeBlockGroup, CodeBlockHeader, CodeBlockShiki } from "@/components/pandacoderz-ui/code-block";
import { Message, MessageAvatar, MessageContent, MessageMarkdown, MessageStack } from "@/components/pandacoderz-ui/message";
import { PromptInput, PromptInputAction, PromptInputActionGroup, PromptInputActions, PromptInputTextarea } from "@/components/pandacoderz-ui/prompt-input";
import { Suggestion, SuggestionList, Suggestions } from "@/components/pandacoderz-ui/suggestions";
import { TextShimmer } from "@/components/pandacoderz-ui/text-shimmer";
import { Thread, ThreadContent, ThreadScrollToBottom } from "@/components/pandacoderz-ui/thread";
import { sleep, uid } from "@/lib/mock-stream";
import { useStreamText } from "@/lib/use-stream-text";
type Version = { id: number; label: string; html: string };
type Turn = { id: string; role: "user" | "assistant"; text: string };
const base = (accent: string, bg: string, fg: string, dark = false) => `<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
:root { color-scheme: ${dark ? "dark" : "light"}; }
body { margin: 0; font: 15px/1.5 Inter, system-ui, sans-serif; background: ${bg}; color: ${fg}; display: grid; place-items: center; min-height: 100vh; }
.card { width: min(360px, 90vw); padding: 28px; border-radius: 20px; background: ${dark ? "#1e293b" : "#fff"}; box-shadow: 0 10px 40px -12px rgba(0,0,0,.2); }
.badge { display: inline-block; font-size: 11px; padding: 4px 10px; border-radius: 999px; background: ${accent}22; color: ${accent}; font-weight: 600; letter-spacing: .02em; }
h1 { font-size: 24px; margin: 14px 0 6px; letter-spacing: -.02em; }
p { margin: 0 0 18px; opacity: .75; }
.price { font-size: 40px; font-weight: 700; letter-spacing: -.03em; }
.price small { font-size: 14px; opacity: .6; font-weight: 500; }
button { width: 100%; padding: 12px; border: 0; border-radius: 12px; background: ${accent}; color: white; font-weight: 600; font-size: 14px; cursor: pointer; }
ul { padding: 0; margin: 16px 0 0; list-style: none; font-size: 13px; }
li { padding: 6px 0; border-top: 1px solid ${dark ? "#334155" : "#eef2f7"}; }
</style>
</head>
<body>
<div class="card">
<span class="badge">PRO</span>
<h1>Everything you need</h1>
<p>For teams shipping AI features to production.</p>
<div class="price">$49 <small>/ month</small></div>
<button>Start free trial</button>
<ul><li>Unlimited projects</li><li>Priority support</li><li>SSO and audit log</li></ul>
</div>
</body>
</html>`;
const versions: Version[] = [
{ id: 1, label: "Pricing card", html: base("#7c3aed", "#f8fafc", "#0f172a") },
{ id: 2, label: "Dark mode", html: base("#a78bfa", "#0f172a", "#f8fafc", true) },
{ id: 3, label: "Emerald accent", html: base("#059669", "#f8fafc", "#0f172a") },
];
const replies = [
"Here's a pricing card with a violet accent, a PRO badge, and a three-item feature list. Want a different palette or layout?",
"Switched the canvas to a dark theme. I kept the accent but lifted it a shade so it still passes contrast on the navy background.",
"Changed the accent to emerald. The gradient badge and the CTA both pick it up automatically since they share one variable.",
];
export type ArtifactCanvasProps = { className?: string };
export default function ArtifactCanvas({ className }: ArtifactCanvasProps) {
const [turns, setTurns] = React.useState<Turn[]>([]);
const [draft, setDraft] = React.useState("");
const [history, setHistory] = React.useState<Version[]>([versions[0]]);
const [current, setCurrent] = React.useState(0);
const [tab, setTab] = React.useState("preview");
const [pendingId, setPendingId] = React.useState<string | null>(null);
const { text, isStreaming, start, stop } = useStreamText(14);
const version = history[current];
const submit = React.useCallback(
async (value: string) => {
const v = value.trim();
if (!v || isStreaming) return;
setDraft("");
const userTurn: Turn = { id: uid("u"), role: "user", text: v };
const assistantId = uid("a");
setTurns((prev) => [...prev, userTurn]);
setPendingId(assistantId);
const nextIndex = history.length % versions.length;
const reply = history.length === 0 ? replies[0] : replies[nextIndex] ?? replies[0];
await sleep(600);
const nextVersion = { ...versions[nextIndex], id: history.length + 1 };
setHistory((prev) => [...prev, nextVersion]);
setCurrent(history.length);
setTab("preview");
await start(reply, () => {
setTurns((prev) => [...prev, { id: assistantId, role: "assistant", text: reply }]);
setPendingId(null);
});
},
[history, isStreaming, start],
);
React.useEffect(() => {
if (turns.length === 0) {
setTurns([{ id: "seed", role: "assistant", text: replies[0] }]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div data-slot="artifact-canvas" className={cn("grid h-full min-h-0 w-full overflow-hidden rounded-3xl border bg-background shadow-xs md:grid-cols-[minmax(0,22rem)_minmax(0,1fr)] lg:grid-cols-[minmax(0,26rem)_minmax(0,1fr)]", className)}>
<section className="flex min-h-0 flex-col border-b md:border-r md:border-b-0">
<header className="flex h-12 shrink-0 items-center gap-2 border-b px-4 text-sm font-medium">
<span className="flex size-6 items-center justify-center rounded-md bg-brand text-primary-foreground"><SparklesIcon size={14} className="flex" /></span>
Canvas chat
</header>
<Thread className="min-h-0 flex-1">
<ThreadContent className="gap-4 p-4">
{turns.map((t) =>
t.role === "user" ? (
<Message key={t.id} from="user"><MessageStack><MessageContent>{t.text}</MessageContent></MessageStack></Message>
) : (
<Message key={t.id} from="assistant" className="max-w-none">
<MessageAvatar fallback={<SparklesIcon size={14} className="flex" />} />
<MessageStack><MessageContent><MessageMarkdown>{t.text}</MessageMarkdown></MessageContent></MessageStack>
</Message>
),
)}
{pendingId ? (
<Message from="assistant" className="max-w-none">
<MessageAvatar fallback={<SparklesIcon size={14} className="flex" />} />
<MessageStack>
<MessageContent>{text ? <MessageMarkdown isAnimating>{text}</MessageMarkdown> : <TextShimmer className="text-sm text-muted-foreground" invertLight>Updating canvas…</TextShimmer>}</MessageContent>
</MessageStack>
</Message>
) : null}
</ThreadContent>
<ThreadScrollToBottom />
</Thread>
<div className="shrink-0 p-3 pt-0">
{turns.length <= 1 ? (
<Suggestions onSelect={submit} className="mb-2">
<SuggestionList>{["Make it dark", "Use an emerald accent"].map((s) => <Suggestion key={s} variant="outline" className="h-7 text-xs">{s}</Suggestion>)}</SuggestionList>
</Suggestions>
) : null}
<PromptInput onSubmit={submit}>
<PromptInputTextarea value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="Describe a change…" />
<PromptInputActions>
<PromptInputActionGroup><span className="px-2 text-xs text-muted-foreground">Editing v{version.id}</span></PromptInputActionGroup>
<PromptInputActionGroup>
{isStreaming ? (
<PromptInputAction asChild tooltip="Stop"><Button size="icon-sm" variant="secondary" className="rounded-full" aria-label="Stop" onClick={stop}><StopIcon size={16} className="flex" /></Button></PromptInputAction>
) : (
<PromptInputAction asChild tooltip={{ content: "Send", shortcut: "↵" }}><Button size="icon-sm" className="rounded-full" aria-label="Send" disabled={!draft.trim()} onClick={() => submit(draft)}><ArrowUpIcon size={16} className="flex" /></Button></PromptInputAction>
)}
</PromptInputActionGroup>
</PromptInputActions>
</PromptInput>
</div>
</section>
<section className="flex min-h-0 flex-col bg-surface">
<Tabs.Root value={tab} onValueChange={setTab} className="flex min-h-0 flex-1 flex-col">
<header className="flex h-12 shrink-0 items-center justify-between gap-2 border-b bg-background px-3">
<Tabs.List className="flex items-center gap-1 rounded-lg bg-muted p-1 text-xs">
<Tabs.Trigger value="preview" className="flex h-7 items-center gap-1.5 rounded-md px-2.5 font-medium text-muted-foreground transition-colors data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-xs"><EyeIcon size={12} className="flex" /> Preview</Tabs.Trigger>
<Tabs.Trigger value="code" className="flex h-7 items-center gap-1.5 rounded-md px-2.5 font-medium text-muted-foreground transition-colors data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-xs"><CodeBracketIcon size={12} className="flex" /> Code</Tabs.Trigger>
</Tabs.List>
<div className="flex items-center gap-1">
<div className="flex items-center gap-0.5 rounded-full border px-1 text-xs">
<Button size="icon-xs" variant="ghost" className="rounded-full" aria-label="Previous version" disabled={current === 0} onClick={() => setCurrent((c) => c - 1)}><ChevronLeftIcon size={12} className="flex" /></Button>
<span className="px-1 font-mono tabular-nums">v{version.id}<span className="text-muted-foreground">/{history.length}</span></span>
<Button size="icon-xs" variant="ghost" className="rounded-full" aria-label="Next version" disabled={current === history.length - 1} onClick={() => setCurrent((c) => c + 1)}><ChevronRightIcon size={12} className="flex" /></Button>
</div>
<Button size="icon-sm" variant="ghost" className="rounded-full text-muted-foreground" aria-label="Download"><ArrowDownTrayIcon size={16} className="flex" /></Button>
</div>
</header>
<Tabs.Content value="preview" className="min-h-0 flex-1 p-3 data-[state=inactive]:hidden">
<iframe key={version.id} title={`Artifact v${version.id}`} srcDoc={version.html} sandbox="" className="size-full rounded-2xl border bg-white" />
</Tabs.Content>
<Tabs.Content value="code" className="min-h-0 flex-1 overflow-hidden p-3 data-[state=inactive]:hidden">
<CodeBlock className="h-full">
<CodeBlockHeader>
<CodeBlockGroup><CodeBracketIcon size={14} className="flex" /><span className="font-mono text-xs">index.html · v{version.id} · {version.label}</span></CodeBlockGroup>
<CodeBlockCopyButton />
</CodeBlockHeader>
<CodeBlockContent className="max-h-none h-[calc(100%-2.375rem)]">
<CodeBlockShiki code={version.html} language="html" lineNumbers />
</CodeBlockContent>
</CodeBlock>
</Tabs.Content>
</Tabs.Root>
</section>
</div>
);
}/**
* Helpers for scripted streaming in demos. Blocks use these to fake a model
* response token by token without touching the network. Swap them for a real
* fetch in production; the UI code does not change.
*/
export function sleep(ms: number, signal?: AbortSignal) {
return new Promise<void>((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason);
const id = setTimeout(resolve, ms);
signal?.addEventListener(
"abort",
() => {
clearTimeout(id);
reject(signal.reason);
},
{ once: true },
);
});
}
/** Split text into word-ish chunks so streaming looks natural. */
export function chunk(text: string): string[] {
return text.match(/\S+\s*|\s+/g) ?? [text];
}
export function jitter(base: number) {
return base + Math.random() * base;
}
/** Yield a string piece by piece with a small random delay between pieces. */
export async function* streamText(
text: string,
signal?: AbortSignal,
delayMs = 14,
): AsyncGenerator<string> {
for (const piece of chunk(text)) {
if (signal?.aborted) return;
yield piece;
await sleep(jitter(delayMs), signal);
}
}
let counter = 0;
export const uid = (prefix = "id") => `${prefix}-${Date.now().toString(36)}-${++counter}`;"use client";
import * as React from "react";
import { streamText } from "./mock-stream";
/**
* Drive a piece of text onto the screen as if it were streaming from a model.
* `start(text)` clears and streams; `stop()` freezes what has arrived.
*/
export function useStreamText(delayMs = 14) {
const [text, setText] = React.useState("");
const [isStreaming, setIsStreaming] = React.useState(false);
const abortRef = React.useRef<AbortController | null>(null);
const stop = React.useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setIsStreaming(false);
}, []);
const start = React.useCallback(
async (full: string, onDone?: () => void) => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setText("");
setIsStreaming(true);
try {
for await (const piece of streamText(full, controller.signal, delayMs)) {
setText((prev) => prev + piece);
}
if (!controller.signal.aborted) onDone?.();
} catch {
/* aborted */
} finally {
if (abortRef.current === controller) {
abortRef.current = null;
setIsStreaming(false);
}
}
},
[delayMs],
);
const reset = React.useCallback(() => {
stop();
setText("");
}, [stop]);
React.useEffect(() => () => abortRef.current?.abort(), []);
return { text, isStreaming, start, stop, reset, setText };
}The registry item pulls in every component it depends on.
Usage
import ArtifactCanvas from "@/components/blocks/artifact-canvas/artifact-canvas";
export default function Page() {
return (
<div className="h-dvh p-4">
<ArtifactCanvas />
</div>
);
}Give the parent a bounded height. The block fills it and scrolls internally.
Going live
Each assistant turn pushes a new Version. Have your model return the full artifact source per turn, or apply its diff to the previous version before pushing. For non-HTML artifacts swap the iframe for your renderer.