AI Patterns

Code Preview Block

A code block that flips between the source and a sandboxed live rendering of it.

JobTrust
Seen in
svg
<svg xmlns="http://www.w3.org/2000/svg"
     width="24" height="24"
     viewBox="0 0 24 24"
     fill="none"
     stroke="currentColor"
     stroke-width="2"
     stroke-linecap="round"
     stroke-linejoin="round"
     class="animated-origami">

  <style>
    .animated-origami path {
      stroke-dasharray: 1;
      stroke-dashoffset: 1;
      animation: fold-draw 3.6s ease-in-out infinite;
    }

    .animated-origami path:nth-child(2) { animation-delay: 0.3s; }
    .animated-origami path:nth-child(3) { animation-delay: 0.6s; }

    @keyframes fold-draw {
      0% { stroke-dashoffset: 1; }
      45%, 65% { stroke-dashoffset: 0; }
      100% { stroke-dashoffset: -1; }
    }
  </style>

  <path pathLength="1" d="M12 12V4a1 1 0 0 1 1-1h6.297a1 1 0 0 1 .651 1.759l-4.696 4.025" />
  <path pathLength="1" d="m12 21-7.414-7.414A2 2 0 0 1 4 12.172V6.415a1.002 1.002 0 0 1 1.707-.707L20 20.009" />
  <path pathLength="1" d="m12.214 3.381 8.414 14.966a1 1 0 0 1-.167 1.199l-1.168 1.163a1 1 0 0 1-.706.291H6.351a1 1 0 0 1-.625-.219L3.25 18.8a1 1 0 0 1 .631-1.781l4.165.027" />
</svg>
"use client";

import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { Check, Code2, Copy, Play, WrapText } from "lucide-react";

import { cn } from "@/lib/utils";

export type PreviewLanguage = "svg" | "html";

export interface CodePreviewBlockProps {
  /** The source to show and render. */
  code: string;
  /** Renderable language. Drives both highlighting and the preview document. */
  lang?: PreviewLanguage;
  /** Start on the rendered preview instead of the code. */
  defaultView?: "code" | "preview";
  /** Height of the preview frame, in px. */
  previewHeight?: number;
  className?: string;
}

export function CodePreviewBlock({
  code,
  lang = "svg",
  defaultView = "code",
  previewHeight = 240,
  className,
}: CodePreviewBlockProps) {
  const [view, setView] = React.useState(defaultView);
  const [wrap, setWrap] = React.useState(false);
  const [copied, setCopied] = React.useState(false);
  const html = useHighlighted(code, lang);
  const rootRef = React.useRef<HTMLDivElement>(null);
  const ink = useInkColor(rootRef, view);
  const preview = view === "preview";

  async function copy() {
    await navigator.clipboard.writeText(code);
    setCopied(true);
    window.setTimeout(() => setCopied(false), 1500);
  }

  return (
    <div
      ref={rootRef}
      className={cn(
        "rounded-2xl border bg-muted/40 text-foreground",
        className,
      )}
    >
      <div className="flex items-center gap-2 py-2 pl-4 pr-2">
        <Code2 className="size-4 text-muted-foreground" aria-hidden />
        <span className="text-sm">{lang}</span>

        <div className="ml-auto flex items-center gap-0.5">
          <AnimatePresence initial={false} mode="popLayout">
            {!preview && (
              <motion.div
                key="code-actions"
                className="flex items-center gap-0.5"
                initial={{ opacity: 0, x: 8 }}
                animate={{ opacity: 1, x: 0 }}
                exit={{ opacity: 0, x: 8 }}
                transition={{ duration: 0.15 }}
              >
                <IconButton
                  label={wrap ? "Don't wrap lines" : "Wrap lines"}
                  pressed={wrap}
                  onClick={() => setWrap((w) => !w)}
                >
                  <WrapText />
                </IconButton>
                <IconButton label={copied ? "Copied" : "Copy"} onClick={copy}>
                  {copied ? <Check /> : <Copy />}
                </IconButton>
              </motion.div>
            )}
          </AnimatePresence>
          <IconButton
            label={preview ? "Code" : "Preview"}
            onClick={() => setView(preview ? "code" : "preview")}
            className="bg-muted"
          >
            <AnimatePresence initial={false} mode="popLayout">
              <motion.span
                key={view}
                className="flex"
                initial={{ opacity: 0, scale: 0.6 }}
                animate={{ opacity: 1, scale: 1 }}
                exit={{ opacity: 0, scale: 0.6 }}
                transition={{ duration: 0.15 }}
              >
                {preview ? <Code2 /> : <Play />}
              </motion.span>
            </AnimatePresence>
          </IconButton>
        </div>
      </div>

      {/* Clip the body, not the root, so header tooltips can overflow the block. */}
      <div className="overflow-hidden rounded-b-2xl">
        <AnimatePresence initial={false} mode="wait">
          {preview ? (
            <motion.div
              key="preview"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={{ duration: 0.15 }}
            >
              <iframe
                title={`${lang} preview`}
                // No allow-same-origin: generated code can't reach the host page.
                sandbox={lang === "html" ? "allow-scripts" : ""}
                srcDoc={toDocument(code, lang, ink)}
                className="block w-full border-0"
                style={{ height: previewHeight }}
              />
            </motion.div>
          ) : (
            <motion.div
              key="code"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={{ duration: 0.15 }}
              className={cn(
                "max-h-[420px] overflow-auto px-4 pb-4 font-mono text-[13px] leading-relaxed",
                "[&_pre]:!bg-transparent [&_code]:block",
                "dark:[&_span]:!text-[var(--shiki-dark)]",
                wrap
                  ? "[&_code]:whitespace-pre-wrap [&_code]:break-words"
                  : "[&_code]:whitespace-pre",
              )}
            >
              {html ? (
                <div dangerouslySetInnerHTML={{ __html: html }} />
              ) : (
                <pre>
                  <code>{code}</code>
                </pre>
              )}
            </motion.div>
          )}
        </AnimatePresence>
      </div>
    </div>
  );
}

function IconButton({
  label,
  pressed,
  onClick,
  className,
  children,
}: {
  label: string;
  pressed?: boolean;
  onClick: () => void;
  className?: string;
  children: React.ReactNode;
}) {
  const [hint, setHint] = React.useState(false);

  return (
    <div className="relative">
      <button
        type="button"
        aria-label={label}
        aria-pressed={pressed}
        onClick={onClick}
        onMouseEnter={() => setHint(true)}
        onMouseLeave={() => setHint(false)}
        onFocus={() => setHint(true)}
        onBlur={() => setHint(false)}
        className={cn(
          "flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors",
          "hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring [&_svg]:size-4",
          pressed && "text-foreground",
          className,
        )}
      >
        {children}
      </button>
      <AnimatePresence>
        {hint && (
          <motion.span
            role="tooltip"
            initial={{ opacity: 0, y: 4 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: 4 }}
            transition={{ duration: 0.12 }}
            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"
          >
            {label}
          </motion.span>
        )}
      </AnimatePresence>
    </div>
  );
}

/** Highlights with Shiki (loaded lazily), in both themes at once via CSS variables. */
function useHighlighted(code: string, lang: PreviewLanguage) {
  const [html, setHtml] = React.useState<string | null>(null);

  React.useEffect(() => {
    let cancelled = false;
    import("shiki").then(({ codeToHtml }) =>
      codeToHtml(code, {
        lang: lang === "svg" ? "xml" : "html",
        themes: { light: "github-light", dark: "github-dark" },
      }).then((out) => {
        if (!cancelled) setHtml(out);
      }),
    );
    return () => {
      cancelled = true;
    };
  }, [code, lang]);

  return html;
}

/** The block's text color, so `currentColor` in the preview matches the theme. */
function useInkColor(ref: React.RefObject<HTMLElement | null>, view: string) {
  const [ink, setInk] = React.useState("#000");
  React.useLayoutEffect(() => {
    if (ref.current) setInk(getComputedStyle(ref.current).color);
  }, [ref, view]);
  return ink;
}

function toDocument(code: string, lang: PreviewLanguage, ink: string) {
  if (lang === "html") return code;
  return `<!doctype html><html><head><style>
html,body{margin:0;height:100%;background:transparent;color:${ink}}
body{display:grid;place-items:center}
svg{width:64px;height:64px}
</style></head><body>${code}</body></html>`;
}

A UX spec for this pattern — written for agents implementing or reusing it, not the code.

# Code Preview Block

## Summary
A 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.

## When to use
- Assistant messages that contain renderable output: SVG icons and illustrations, HTML/CSS snippets, small widgets.
- Anywhere the visual result matters more than the text, but the user may still want to copy or check the source.

## When not to use
- 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.
- Large, multi-file apps; use a full artifact/canvas view with its own panel instead.
- When previews would run untrusted code without isolation. If you can't sandbox it, don't offer it.

## Anatomy
- Header: a code glyph and the language name on the left; actions on the right.
- Code actions (code view only): wrap lines toggle, copy.
- View toggle: the right-most, filled round button. Shows a play glyph ("Preview") in code view, a code glyph ("Code") in preview view.
- Tooltips: a dark pill above each icon button, naming its action.
- Body: syntax-highlighted source, or a sandboxed frame with the rendered result centered on the block's background.

## Behavior
- Opens on the code by default (the model wrote code; show what it wrote). Pass `defaultView="preview"` when the rendering is the point.
- Preview swaps the body in place with a short cross-fade; the header stays fixed so the toggle doesn't move under the cursor.
- In preview the wrap and copy actions slide away — they act on text that isn't visible — leaving only the toggle back to Code.
- The toggle's glyph cross-fades and scales between play and code.
- Copy confirms with a check glyph and a "Copied" tooltip for ~1.5s.
- 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.
- SVGs are centered and scaled up to a readable size; `currentColor` takes the block's text color so the preview follows light/dark theme.
- Highlighting loads lazily; the plain source shows until it's ready, so the block never renders empty.

## Content guidelines
- Label the language plainly and in lowercase as written in the fence ("svg", "html").
- Tooltip names are verbs or the destination view: "Preview", "Code", "Copy", "Wrap lines".

## Accessibility
- Every icon button has an `aria-label` matching its tooltip; tooltips also show on keyboard focus.
- The wrap toggle exposes `aria-pressed`.
- The preview iframe has a `title` ("svg preview").
- 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.

## Related patterns
- **Diff Tabs** — Use it instead when the code is a change to existing files and people need to switch between diff views.
- **Drawing Icon** — Use it as well when the code is an animated SVG that is worth previewing live.

Do’s and don’ts

Do use it for

  • Assistant messages that contain renderable output: SVG icons and illustrations, HTML/CSS snippets, small widgets.
  • Anywhere the visual result matters more than the text, but the user may still want to copy or check the source.

Don’t use it for

  • 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.
  • Large, multi-file apps; use a full artifact/canvas view with its own panel instead.
  • When previews would run untrusted code without isolation. If you can't sandbox it, don't offer it.