Aadi

Web Preview

Component

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>
  )
}

Dependencies

More by Aadi

View all
  • Dither Orb

    Dither Orb

    12
  • Audio Visualizer

    Audio Visualizer

    9
  • Voice Orb

    Voice Orb

    8
  • Agent Lanes

    Agent Lanes

    6
  • Computer View

    Computer View

    3
  • Waveform

    Waveform

    2
  • Reasoning

    Reasoning

    2
  • Voice Session

    Voice Session

    1

Explore more

Part of chatcn — browse the full library on 21st.dev.