Code Preview Block
A code block that flips between the source and a sandboxed live rendering of it.
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.