About
A live preview of the app an agent is building: browser chrome with back, forward, reload and the URL, device sizes, open in a new tab, and a console drawer with the page logs.
Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Web Preview to your project and fits it to your theme.
Code
How Web Preview is used: the demo's code. The component's own source comes with a free account.
Usage.tsx
"use client"
import * as React from "react"
import { WebPreview, WebPreviewLog } from "@/components/ui/web-preview"
const FIT_CSS = "html,body{overflow:hidden}[data-fit-small] [data-fit-hide]{display:none!important}"
/**
* 21st shows a demo in a small frame: about 674 by 421 on a component page, 358 by 224 on a
* phone and 245 by 139 on a profile card. A demo taller than its frame gets scrollbars, which
* render white on dark pages. FitFrame fills the frame exactly instead.
*
* - "scale": the content shrinks until it fits, centered. For visuals such as orbs.
* - "crop": the content stays at full size from the top and fades out at the bottom. For text
* that streams in, where a changing scale would make the page breathe.
*
* `minWidth` lays out a wide demo (a table, a two column panel) at that width and scales it down
* to the frame, instead of letting it collapse to a narrow layout. The frame centers it, so it
* shrinks evenly on both sides.
*
* Anything marked `data-fit-hide` (state buttons, the chatcn.dev line) drops out in frames
* shorter than `hideBelow`, so a profile card shows the visual alone.
*/
function FitFrame({
children,
mode = "scale",
hideBelow = 360,
minWidth,
}: {
children: React.ReactNode
mode?: "scale" | "crop"
hideBelow?: number
minWidth?: string
}) {
const content = React.useRef<HTMLDivElement>(null)
const [cropped, setCropped] = React.useState(false)
React.useLayoutEffect(() => {
const el = content.current
if (!el) return
const measure = () => {
const vw = window.innerWidth
const vh = window.innerHeight
el.toggleAttribute("data-fit-small", vh < hideBelow)
// Layout sizes ignore the transform, so measuring never feeds back.
const w = Math.max(el.offsetWidth, el.scrollWidth)
const h = Math.max(el.offsetHeight, el.scrollHeight)
const scale = mode === "scale" ? Math.min(1, vw / w, vh / h) : 1
el.style.transform = scale < 1 ? `scale(${scale})` : ""
setCropped(mode === "crop" && h > vh + 1)
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(el)
// Content that changes inside a fixed width box (a headline that cycles words) never resizes
// the box itself, so changes below it measure again on the next frame.
let frame = 0
const mutations = new MutationObserver(() => {
cancelAnimationFrame(frame)
frame = requestAnimationFrame(measure)
})
mutations.observe(el, { subtree: true, childList: true, characterData: true })
window.addEventListener("resize", measure)
return () => {
observer.disconnect()
mutations.disconnect()
cancelAnimationFrame(frame)
window.removeEventListener("resize", measure)
}
}, [mode, hideBelow])
const fade = "linear-gradient(to bottom, #000 calc(100% - 40px), transparent)"
return (
<div
className="flex w-full justify-center overflow-hidden"
style={{
height: "100dvh",
alignItems: mode === "crop" ? "flex-start" : "center",
maskImage: cropped ? fade : undefined,
WebkitMaskImage: cropped ? fade : undefined,
}}
>
<style>{FIT_CSS}</style>
<div
ref={content}
className="w-full shrink-0"
style={{ transformOrigin: mode === "crop" ? "top center" : "center", minWidth }}
>
{children}
</div>
</div>
)
}
/*
* A dev server preview. The page inside the sandboxed iframe talks to the host with postMessage:
* links ask the host to navigate, and console calls are forwarded to the console drawer.
*/
const ORIGIN = "http://localhost:3000"
// The demo's times count back from the moment it renders. Read through `performance`, which
// Next.js allows while it prerenders a page; `Date.now()` during render fails the build there.
const demoNow = () => performance.timeOrigin + performance.now()
const now = () => demoNow()
const RUNS = [
{ id: "482", name: "Fix flaky checkout test", status: "Passed", time: "2 m 14 s" },
{ id: "481", name: "Usage meter card", status: "Passed", time: "48 s" },
{ id: "480", name: "Migrate runs to object storage", status: "Failed", time: "6 m 02 s" },
{ id: "479", name: "Rate limit the webhooks API", status: "Passed", time: "1 m 37 s" },
{ id: "478", name: "Q3 incident review", status: "Cancelled", time: "12 s" },
]
const STYLE = `
:root { color-scheme: light dark; font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; }
* { box-sizing: border-box; }
body { margin: 0; background: Canvas; color: CanvasText; font-size: 14px; }
a { color: inherit; text-decoration: none; }
nav { display: flex; align-items: center; gap: 20px; height: 52px; padding: 0 24px;
border-bottom: 1px solid color-mix(in oklab, CanvasText 10%, Canvas); }
nav b { font-weight: 600; margin-right: auto; letter-spacing: -0.01em; }
nav a { color: color-mix(in oklab, CanvasText 55%, Canvas); }
nav a[aria-current] { color: CanvasText; }
main { max-width: 720px; margin: 0 auto; padding: 32px 24px; }
h1 { font-size: 22px; font-weight: 600; letter-spacing: -0.02em; margin: 0 0 4px; }
.sub { color: color-mix(in oklab, CanvasText 55%, Canvas); margin: 0 0 24px; }
.row { display: grid; grid-template-columns: 56px 1fr auto 80px; gap: 12px; align-items: center;
padding: 12px 0; border-top: 1px solid color-mix(in oklab, CanvasText 10%, Canvas); }
.muted { color: color-mix(in oklab, CanvasText 55%, Canvas); font-variant-numeric: tabular-nums; }
.right { text-align: right; }
.stat { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 28px; }
.stat div { padding: 14px; border-radius: 10px; background: color-mix(in oklab, CanvasText 4%, Canvas); }
.stat strong { display: block; font-size: 20px; font-weight: 600; font-variant-numeric: tabular-nums; }
pre { font: 13px/20px ui-monospace, monospace; padding: 14px; border-radius: 10px;
background: color-mix(in oklab, CanvasText 4%, Canvas); overflow: auto; }
`
const BRIDGE = `
document.addEventListener("click", function (e) {
var a = e.target.closest("a[data-path]");
if (!a) return;
e.preventDefault();
parent.postMessage({ source: "chatcn-preview", type: "navigate", path: a.dataset.path }, "*");
});
["log", "info", "warn", "error"].forEach(function (level) {
var original = console[level];
console[level] = function () {
var message = Array.prototype.map.call(arguments, String).join(" ");
parent.postMessage({ source: "chatcn-preview", type: "log", level: level, message: message }, "*");
original.apply(console, arguments);
};
});
`
function nav(path: string) {
const link = (to: string, label: string) =>
`<a href="${to}" data-path="${to}"${path === to || (to !== "/" && path.startsWith(to)) ? " aria-current" : ""}>${label}</a>`
return `<nav><b>Relay</b>${link("/", "Runs")}${link("/billing", "Billing")}${link("/settings", "Settings")}</nav>`
}
function page(path: string, body: string, script = "") {
return `<!doctype html><html lang="en"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><style>${STYLE}</style><script>${BRIDGE}</script></head><body>${nav(path)}<main>${body}</main><script>${script}</script></body></html>`
}
function render(path: string): { html: string; error?: string } {
if (path === "/") {
const rows = RUNS.map(
(r) =>
`<a class="row" href="/runs/${r.id}" data-path="/runs/${r.id}"><span class="muted">#${r.id}</span><span>${r.name}</span><span class="muted">${r.status}</span><span class="muted right">${r.time}</span></a>`,
).join("")
return {
html: page(
path,
`<h1>Runs</h1><p class="sub">Last 20 runs across all projects</p><div class="stat"><div><span class="muted">Passed</span><strong>17</strong></div><div><span class="muted">Failed</span><strong>2</strong></div><div><span class="muted">Median</span><strong>1 m 12 s</strong></div></div>${rows}`,
`console.log("Fetched 20 runs in 84 ms");`,
),
}
}
if (path.startsWith("/runs/")) {
const run = RUNS.find((r) => path.endsWith(r.id)) ?? RUNS[0]
return {
html: page(
path,
`<p class="muted"><a href="/" data-path="/">Runs</a> / #${run?.id}</p><h1>${run?.name}</h1><p class="sub">${run?.status} in ${run?.time}</p><pre>$ pnpm test --filter web\n ✓ src/app/page.test.tsx (3 tests) 412ms\n ✓ src/components/recent-runs.test.tsx (5 tests) 188ms\n\n Test Files 2 passed (2)\n Tests 8 passed (8)</pre>`,
`console.warn("Each child in a list should have a unique key prop. Check the render method of StepList.");`,
),
}
}
if (path === "/billing") {
return {
html: page(path, ""),
error:
"GET /billing returned 500. TypeError: Cannot read properties of undefined (reading 'plan') in app/billing/page.tsx:14. Check that the session has a team before reading its plan.",
}
}
return {
html: page(
path,
`<h1>Settings</h1><p class="sub">Team, members and API keys</p>`,
`console.info("Settings rendered");`,
),
}
}
function WebPreviewDemo() {
const [url, setUrl] = React.useState(`${ORIGIN}/`)
const [loading, setLoading] = React.useState(true)
const [attempt, setAttempt] = React.useState(0)
// The previous page stays up while the next one compiles, the way a browser behaves.
const [doc, setDoc] = React.useState("")
const [logs, setLogs] = React.useState<WebPreviewLog[]>([
{
level: "info",
message: "Ready on http://localhost:3000",
timestamp: now(),
source: "next dev",
},
])
const path = url.startsWith(ORIGIN) ? url.slice(ORIGIN.length) || "/" : "/"
const result = render(path)
// Each navigation compiles for a moment, like a dev server, then the page appears.
React.useEffect(() => {
setLoading(true)
const id = window.setTimeout(() => {
setLoading(false)
setDoc(result.html)
if (result.error)
setLogs((l) => [
...l,
{
level: "error",
message: result.error as string,
timestamp: now(),
source: "app/billing/page.tsx:14",
},
])
}, 900)
return () => window.clearTimeout(id)
}, [url, attempt])
React.useEffect(() => {
const onMessage = (event: MessageEvent) => {
const data = event.data as {
source?: string
type?: string
path?: string
level?: WebPreviewLog["level"]
message?: string
}
if (data?.source !== "chatcn-preview") return
if (data.type === "navigate" && data.path) setUrl(`${ORIGIN}${data.path}`)
if (data.type === "log" && data.level && data.message) {
const entry: WebPreviewLog = { level: data.level, message: data.message, timestamp: now() }
setLogs((l) => [...l, entry])
}
}
window.addEventListener("message", onMessage)
return () => window.removeEventListener("message", onMessage)
}, [])
return (
<WebPreview
className="h-[560px] w-full max-w-5xl"
url={url}
onUrlChange={setUrl}
srcDoc={doc}
loading={loading}
error={
!loading && result.error
? {
title: "This page crashed",
message:
"The server returned 500 for /billing. Open the console for the stack, or ask the agent to fix it.",
}
: null
}
onRetry={() => setAttempt((n) => n + 1)}
logs={logs}
onClearLogs={() => setLogs([])}
frameTitle="Relay dev server"
/>
)
}
/** The line under each 21st demo that points back to the kit. */
function MoreOn({ slug, children }: { slug: string; children: React.ReactNode }) {
return (
<p data-fit-hide className="text-[0.75rem]/[1rem] text-(color:--chatcn-fg-faint) text-center text-balance">
{children}{" "}
<a
href={`https://chatcn.dev/docs/components/${slug}`}
target="_blank"
rel="noopener"
className="text-(color:--chatcn-fg-subtle) hover:text-(color:--chatcn-fg) underline underline-offset-2"
>
chatcn.dev
</a>
</p>
)
}
/** The kit's own example, framed for the 21st preview. */
export default function WebPreviewPreview() {
return (
<FitFrame minWidth="40rem">
<div className="flex w-full flex-col items-center justify-center gap-4 p-6">
<div className="flex w-full max-w-2xl justify-center">
<WebPreviewDemo />
</div>
<MoreOn slug="web-preview">A live preview of the app an agent is building. On</MoreOn>
</div>
</FitFrame>
)
}








