{"slug":"code-preview","category":"code","job":"trust","added":"2026-11-05T11:03Z","title":"Code Preview Block","description":"A code block that flips between the source and a sandboxed live rendering of it.","url":"https://ai-patterns.app/patterns/code/code-preview","markdown":"https://ai-patterns.app/patterns/code/code-preview.md","json":"https://ai-patterns.app/api/patterns/code/code-preview","seenIn":[{"team":"chatgpt","source":"https://help.openai.com/en/articles/9930697-what-is-the-canvas-feature-in-chatgpt-and-how-do-i-use-it"}],"spec":"# Code Preview Block\n\n## Summary\nA code block in an AI response that can flip between the source and a live rendering of it. When the model writes something renderable (an SVG, a self-contained HTML snippet), the user can see what it does in place, without copying it anywhere, then flip back to the code.\n\n## When to use\n- Assistant messages that contain renderable output: SVG icons and illustrations, HTML/CSS snippets, small widgets.\n- Anywhere the visual result matters more than the text, but the user may still want to copy or check the source.\n\n## When not to use\n- Code that can't render on its own (a function, a config file, a React component without a runtime). Show a normal code block — a Preview button that renders nothing or errors breaks trust.\n- Large, multi-file apps; use a full artifact/canvas view with its own panel instead.\n- When previews would run untrusted code without isolation. If you can't sandbox it, don't offer it.\n\n## Anatomy\n- Header: a code glyph and the language name on the left; actions on the right.\n- Code actions (code view only): wrap lines toggle, copy.\n- View toggle: the right-most, filled round button. Shows a play glyph (\"Preview\") in code view, a code glyph (\"Code\") in preview view.\n- Tooltips: a dark pill above each icon button, naming its action.\n- Body: syntax-highlighted source, or a sandboxed frame with the rendered result centered on the block's background.\n\n## Behavior\n- Opens on the code by default (the model wrote code; show what it wrote). Pass `defaultView=\"preview\"` when the rendering is the point.\n- Preview swaps the body in place with a short cross-fade; the header stays fixed so the toggle doesn't move under the cursor.\n- In preview the wrap and copy actions slide away — they act on text that isn't visible — leaving only the toggle back to Code.\n- The toggle's glyph cross-fades and scales between play and code.\n- Copy confirms with a check glyph and a \"Copied\" tooltip for ~1.5s.\n- The preview renders in an iframe with `sandbox` and no `allow-same-origin`: SVG gets no script at all, HTML gets scripts but can't touch the host page, cookies or storage.\n- SVGs are centered and scaled up to a readable size; `currentColor` takes the block's text color so the preview follows light/dark theme.\n- Highlighting loads lazily; the plain source shows until it's ready, so the block never renders empty.\n\n## Content guidelines\n- Label the language plainly and in lowercase as written in the fence (\"svg\", \"html\").\n- Tooltip names are verbs or the destination view: \"Preview\", \"Code\", \"Copy\", \"Wrap lines\".\n\n## Accessibility\n- Every icon button has an `aria-label` matching its tooltip; tooltips also show on keyboard focus.\n- The wrap toggle exposes `aria-pressed`.\n- The preview iframe has a `title` (\"svg preview\").\n- Animated previews still respect the user's `prefers-reduced-motion` inside the frame if the generated code does; the block's own transitions are short opacity/scale fades.\n\n## Related patterns\n- **Diff Tabs** — Use it instead when the code is a change to existing files and people need to switch between diff views.\n- **Drawing Icon** — Use it as well when the code is an animated SVG that is worth previewing live.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { Check, Code2, Copy, Play, WrapText } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport type PreviewLanguage = \"svg\" | \"html\";\n\nexport interface CodePreviewBlockProps {\n  /** The source to show and render. */\n  code: string;\n  /** Renderable language. Drives both highlighting and the preview document. */\n  lang?: PreviewLanguage;\n  /** Start on the rendered preview instead of the code. */\n  defaultView?: \"code\" | \"preview\";\n  /** Height of the preview frame, in px. */\n  previewHeight?: number;\n  className?: string;\n}\n\nexport function CodePreviewBlock({\n  code,\n  lang = \"svg\",\n  defaultView = \"code\",\n  previewHeight = 240,\n  className,\n}: CodePreviewBlockProps) {\n  const [view, setView] = React.useState(defaultView);\n  const [wrap, setWrap] = React.useState(false);\n  const [copied, setCopied] = React.useState(false);\n  const html = useHighlighted(code, lang);\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const ink = useInkColor(rootRef, view);\n  const preview = view === \"preview\";\n\n  async function copy() {\n    await navigator.clipboard.writeText(code);\n    setCopied(true);\n    window.setTimeout(() => setCopied(false), 1500);\n  }\n\n  return (\n    <div\n      ref={rootRef}\n      className={cn(\n        \"rounded-2xl border bg-muted/40 text-foreground\",\n        className,\n      )}\n    >\n      <div className=\"flex items-center gap-2 py-2 pl-4 pr-2\">\n        <Code2 className=\"size-4 text-muted-foreground\" aria-hidden />\n        <span className=\"text-sm\">{lang}</span>\n\n        <div className=\"ml-auto flex items-center gap-0.5\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {!preview && (\n              <motion.div\n                key=\"code-actions\"\n                className=\"flex items-center gap-0.5\"\n                initial={{ opacity: 0, x: 8 }}\n                animate={{ opacity: 1, x: 0 }}\n                exit={{ opacity: 0, x: 8 }}\n                transition={{ duration: 0.15 }}\n              >\n                <IconButton\n                  label={wrap ? \"Don't wrap lines\" : \"Wrap lines\"}\n                  pressed={wrap}\n                  onClick={() => setWrap((w) => !w)}\n                >\n                  <WrapText />\n                </IconButton>\n                <IconButton label={copied ? \"Copied\" : \"Copy\"} onClick={copy}>\n                  {copied ? <Check /> : <Copy />}\n                </IconButton>\n              </motion.div>\n            )}\n          </AnimatePresence>\n          <IconButton\n            label={preview ? \"Code\" : \"Preview\"}\n            onClick={() => setView(preview ? \"code\" : \"preview\")}\n            className=\"bg-muted\"\n          >\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              <motion.span\n                key={view}\n                className=\"flex\"\n                initial={{ opacity: 0, scale: 0.6 }}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={{ opacity: 0, scale: 0.6 }}\n                transition={{ duration: 0.15 }}\n              >\n                {preview ? <Code2 /> : <Play />}\n              </motion.span>\n            </AnimatePresence>\n          </IconButton>\n        </div>\n      </div>\n\n      {/* Clip the body, not the root, so header tooltips can overflow the block. */}\n      <div className=\"overflow-hidden rounded-b-2xl\">\n        <AnimatePresence initial={false} mode=\"wait\">\n          {preview ? (\n            <motion.div\n              key=\"preview\"\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{ opacity: 0 }}\n              transition={{ duration: 0.15 }}\n            >\n              <iframe\n                title={`${lang} preview`}\n                // No allow-same-origin: generated code can't reach the host page.\n                sandbox={lang === \"html\" ? \"allow-scripts\" : \"\"}\n                srcDoc={toDocument(code, lang, ink)}\n                className=\"block w-full border-0\"\n                style={{ height: previewHeight }}\n              />\n            </motion.div>\n          ) : (\n            <motion.div\n              key=\"code\"\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{ opacity: 0 }}\n              transition={{ duration: 0.15 }}\n              className={cn(\n                \"max-h-[420px] overflow-auto px-4 pb-4 font-mono text-[13px] leading-relaxed\",\n                \"[&_pre]:!bg-transparent [&_code]:block\",\n                \"dark:[&_span]:!text-[var(--shiki-dark)]\",\n                wrap\n                  ? \"[&_code]:whitespace-pre-wrap [&_code]:break-words\"\n                  : \"[&_code]:whitespace-pre\",\n              )}\n            >\n              {html ? (\n                <div dangerouslySetInnerHTML={{ __html: html }} />\n              ) : (\n                <pre>\n                  <code>{code}</code>\n                </pre>\n              )}\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n    </div>\n  );\n}\n\nfunction IconButton({\n  label,\n  pressed,\n  onClick,\n  className,\n  children,\n}: {\n  label: string;\n  pressed?: boolean;\n  onClick: () => void;\n  className?: string;\n  children: React.ReactNode;\n}) {\n  const [hint, setHint] = React.useState(false);\n\n  return (\n    <div className=\"relative\">\n      <button\n        type=\"button\"\n        aria-label={label}\n        aria-pressed={pressed}\n        onClick={onClick}\n        onMouseEnter={() => setHint(true)}\n        onMouseLeave={() => setHint(false)}\n        onFocus={() => setHint(true)}\n        onBlur={() => setHint(false)}\n        className={cn(\n          \"flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors\",\n          \"hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring [&_svg]:size-4\",\n          pressed && \"text-foreground\",\n          className,\n        )}\n      >\n        {children}\n      </button>\n      <AnimatePresence>\n        {hint && (\n          <motion.span\n            role=\"tooltip\"\n            initial={{ opacity: 0, y: 4 }}\n            animate={{ opacity: 1, y: 0 }}\n            exit={{ opacity: 0, y: 4 }}\n            transition={{ duration: 0.12 }}\n            className=\"pointer-events-none absolute bottom-full right-0 z-10 mb-1.5 whitespace-nowrap rounded-full bg-foreground px-2.5 py-1 text-xs font-medium text-background shadow-md\"\n          >\n            {label}\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\n/** Highlights with Shiki (loaded lazily), in both themes at once via CSS variables. */\nfunction useHighlighted(code: string, lang: PreviewLanguage) {\n  const [html, setHtml] = React.useState<string | null>(null);\n\n  React.useEffect(() => {\n    let cancelled = false;\n    import(\"shiki\").then(({ codeToHtml }) =>\n      codeToHtml(code, {\n        lang: lang === \"svg\" ? \"xml\" : \"html\",\n        themes: { light: \"github-light\", dark: \"github-dark\" },\n      }).then((out) => {\n        if (!cancelled) setHtml(out);\n      }),\n    );\n    return () => {\n      cancelled = true;\n    };\n  }, [code, lang]);\n\n  return html;\n}\n\n/** The block's text color, so `currentColor` in the preview matches the theme. */\nfunction useInkColor(ref: React.RefObject<HTMLElement | null>, view: string) {\n  const [ink, setInk] = React.useState(\"#000\");\n  React.useLayoutEffect(() => {\n    if (ref.current) setInk(getComputedStyle(ref.current).color);\n  }, [ref, view]);\n  return ink;\n}\n\nfunction toDocument(code: string, lang: PreviewLanguage, ink: string) {\n  if (lang === \"html\") return code;\n  return `<!doctype html><html><head><style>\nhtml,body{margin:0;height:100%;background:transparent;color:${ink}}\nbody{display:grid;place-items:center}\nsvg{width:64px;height:64px}\n</style></head><body>${code}</body></html>`;\n}\n","dependencies":["lucide-react","motion"],"localImports":["@/lib/utils"]}