Embedded dev-server port preview frame for a coding agent: live localhost address bar, Desktop/Mobile viewport toggle, reload button and HMR console drawer.
Set your access key from API keys as NEXTUI_TOKEN in .env.local and add the @nextui namespace below to components.json once. Then every item installs by name. Each install counts as a copy on a free account.
$pnpm dlx shadcn@latest add @nextui/sandbox-preview
1{2"registries": {3"@nextui": {4"url": "https://nextui.dev/r/{name}.json",5"headers": { "Authorization": "Bearer ${NEXTUI_TOKEN}" }6}7}8}
1"use client";23import { SandboxPreview } from "@/components/nextui/sandbox-preview";45export function DevServerFrame() {6return <SandboxPreview port={3000} route="/checkout" />;7}
| Prop | Type | Default | Description |
|---|---|---|---|
| port | number | 3000 | Local dev server port |
| route | string | "/checkout" | Previewed route path |
| logs | (string | SandboxLog)[] | – | Dev server stdout/HMR lines shown in the bottom console drawer |
| children | ReactNode | – | Your own preview content; replaces the scripted checkout page |
| onReload | () => void | – | Called when Reload is pressed |
| autoPlay | boolean | false | Loop a responsive viewport and reload demo |
| className | string | – | Extra classes on the wrapper |
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { ChevronDown, Monitor, RotateCw, Smartphone, SquareTerminal, Zap } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export type SandboxLog = { stream: "stdout" | "stderr"; text: string; time?: string };
export type SandboxPreviewProps = {
port?: number;
route?: string;
/** Console lines. Plain strings are treated as stdout. */
logs?: (string | SandboxLog)[];
/** Render your own app frame (e.g. an iframe) instead of the demo page. */
children?: React.ReactNode;
onReload?: () => void;
/** Loop a scripted boot → mobile → HMR → reload demo. */
autoPlay?: boolean;
className?: string;
};
const SCRIPT: (SandboxLog & { at: number })[] = [
{ at: 200, stream: "stdout", text: "▲ ready · http://localhost:3000", time: "12:04:01" },
{ at: 600, stream: "stdout", text: "○ compiling /checkout …", time: "12:04:01" },
{ at: 1400, stream: "stdout", text: "✓ compiled /checkout in 812ms", time: "12:04:02" },
{ at: 4000, stream: "stdout", text: "✓ hmr · price-card.tsx updated in 94ms", time: "12:04:09" },
{ at: 5600, stream: "stderr", text: "⚠ Each child in a list should have a unique \"key\" prop. (plan-list.tsx:18)", time: "12:04:11" },
{ at: 8300, stream: "stdout", text: "GET /checkout 200 in 38ms", time: "12:04:14" },
];
const EASE = [0.22, 1, 0.36, 1] as const;
const TICK = 100;
const LOOP = 11000;
function useDemoClock(enabled: boolean, loop: number) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const el = ref.current;
if (!el || !enabled) return;
const io = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));
io.observe(el);
return () => io.disconnect();
}, [enabled]);
useEffect(() => {
if (!enabled || !visible) return;
const id = setInterval(() => setElapsed((e) => e + TICK), TICK);
return () => clearInterval(id);
}, [enabled, visible]);
return { ref, t: elapsed % loop };
}
type Filter = "all" | "stdout" | "stderr";
type View = { viewport: "desktop" | "mobile"; loading: number | null; reloadKey: number; toast: boolean; price: number; filter: Filter; logCount: number };
function demoView(t: number): View {
return {
viewport: t >= 2600 && t < 8900 ? "mobile" : "desktop",
loading: t >= 500 && t < 1500 ? (t - 500) / 1000 : t >= 7800 && t < 8500 ? (t - 7800) / 700 : t < 500 ? 0 : null,
reloadKey: t >= 7800 ? 1 : 0,
toast: t >= 4000 && t < 5600,
price: t >= 4000 ? 24 : 29,
filter: t >= 6400 && t < 7600 ? "stderr" : "all",
logCount: SCRIPT.filter((l) => t >= l.at).length,
};
}
/** A live dev-server preview: address bar with port, desktop / mobile frame, reload, HMR toast and a stdout / stderr console drawer. */
export function SandboxPreview({
port = 3000,
route = "/checkout",
logs: logsProp,
children,
onReload,
autoPlay = false,
className,
}: SandboxPreviewProps) {
const reduce = useReducedMotion() ?? false;
const uid = useId();
const [touched, setTouched] = useState(false);
const [consoleOpen, setConsoleOpen] = useState(true);
const [view, setView] = useState<View>({ viewport: "desktop", loading: null, reloadKey: 0, toast: false, price: 24, filter: "all", logCount: SCRIPT.length });
const demo = autoPlay && !reduce && !touched;
const { ref, t } = useDemoClock(demo, LOOP);
// Sync the scripted frame during render (no effect → no extra commit per tick).
const [syncedT, setSyncedT] = useState(-1);
if (demo && t !== syncedT) {
setSyncedT(t);
setView(demoView(t));
}
const logs: SandboxLog[] = logsProp ? logsProp.map((l) => (typeof l === "string" ? { stream: "stdout", text: l } : l)) : SCRIPT.slice(0, view.logCount);
const errors = logs.filter((l) => l.stream === "stderr").length;
const shown = logs.filter((l) => view.filter === "all" || l.stream === view.filter).slice(-4);
const mobile = view.viewport === "mobile";
const set = (p: Partial<View>) => {
setTouched(true);
setView((v) => ({ ...v, ...p }));
};
const reload = () => {
onReload?.();
set({ loading: 0, reloadKey: view.reloadKey + 1 });
let p = 0;
const id = setInterval(() => {
p += 0.2;
setView((v) => ({ ...v, loading: p >= 1 ? null : p }));
if (p >= 1) clearInterval(id);
}, 80);
};
return (
<div ref={ref} className={cn("w-full max-w-md text-sm", className)}>
<div className="overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-[0_1px_2px_rgb(0_0_0/0.04),0_12px_32px_-18px_rgb(0_0_0/0.25)]">
{/* Toolbar */}
<div className="relative flex items-center gap-2 border-b bg-gradient-to-b from-muted/60 to-muted/20 px-2.5 py-2">
<div className="flex h-7 min-w-0 flex-1 items-center gap-2 rounded-lg border bg-background px-2 shadow-xs">
<span className="relative grid size-2 shrink-0 place-items-center" aria-label="Server running">
{!reduce && <span className="absolute size-2 animate-ping rounded-full bg-emerald-500/60 [animation-duration:2s]" />}
<span className="relative size-2 rounded-full bg-emerald-500" />
</span>
<span className="truncate font-mono text-[11px]">
<span className="text-muted-foreground">localhost:</span>
<span className="font-semibold">{port}</span>
<span className="text-muted-foreground">{route}</span>
</span>
</div>
<div className="relative inline-flex shrink-0 rounded-lg border bg-muted/60 p-0.5" role="radiogroup" aria-label="Viewport">
{(["desktop", "mobile"] as const).map((v) => {
const on = view.viewport === v;
const Icon = v === "desktop" ? Monitor : Smartphone;
return (
<button
key={v}
type="button"
role="radio"
aria-checked={on}
aria-label={`${v} viewport`}
onClick={() => set({ viewport: v })}
className={cn("relative grid size-6 cursor-pointer place-items-center rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring", on ? "text-foreground" : "text-muted-foreground hover:text-foreground")}
>
{on && <motion.span layoutId={`${uid}-vp`} className="absolute inset-0 rounded-md bg-background shadow-xs" transition={{ type: "spring", stiffness: 500, damping: 36 }} />}
<Icon className="relative size-3.5" aria-hidden />
</button>
);
})}
</div>
<button
type="button"
aria-label="Reload preview"
onClick={reload}
className="grid size-7 shrink-0 cursor-pointer place-items-center rounded-lg border bg-background text-muted-foreground shadow-xs outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<motion.span key={view.reloadKey} initial={reduce || view.reloadKey === 0 ? false : { rotate: 0 }} animate={{ rotate: 360 }} transition={{ duration: 0.6, ease: EASE }}>
<RotateCw className="size-3.5" aria-hidden />
</motion.span>
</button>
{view.loading !== null && (
<span className="absolute inset-x-0 bottom-0 h-0.5" aria-hidden>
<span className="block h-full bg-foreground transition-[width] duration-100 ease-linear" style={{ width: `${view.loading * 100}%` }} />
</span>
)}
</div>
{/* Stage */}
<div
className="relative flex h-44 items-center justify-center overflow-hidden p-3"
style={{ backgroundImage: "radial-gradient(color-mix(in oklab, var(--foreground) 12%, transparent) 1px, transparent 1px)", backgroundSize: "12px 12px" }}
>
<motion.div
initial={false}
animate={{ width: mobile ? 132 : "100%", borderRadius: mobile ? 20 : 10 }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 180, damping: 26 }}
className={cn("relative h-full overflow-hidden border bg-background shadow-[0_8px_24px_-12px_rgb(0_0_0/0.3)]", mobile && "border-[3px] border-foreground/80")}
>
{mobile && <span className="absolute top-1 left-1/2 z-10 h-1.5 w-10 -translate-x-1/2 rounded-full bg-foreground/80" aria-hidden />}
<motion.div
className="size-full"
animate={{ opacity: view.loading !== null && view.loading < 0.7 ? 0.25 : 1 }}
transition={{ duration: 0.2 }}
>
{children ?? <CheckoutPage compact={mobile} price={view.price} reduce={reduce} />}
</motion.div>
</motion.div>
<AnimatePresence>
{view.toast && (
<motion.div
initial={reduce ? false : { opacity: 0, y: 12, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduce ? undefined : { opacity: 0, y: 6 }}
transition={{ type: "spring", stiffness: 420, damping: 28 }}
className="absolute right-3 bottom-3 inline-flex items-center gap-1.5 rounded-full border bg-card py-1 pr-2.5 pl-1 text-[11px] font-medium shadow-lg"
role="status"
>
<span className="grid size-5 place-items-center rounded-full bg-amber-400 text-black">
<Zap className="size-3 fill-current" aria-hidden />
</span>
Hot reloaded · 94ms
</motion.div>
)}
</AnimatePresence>
</div>
{/* Console */}
<div className="border-t">
<div className="flex items-center gap-2 px-2.5 py-1.5">
<button
type="button"
aria-expanded={consoleOpen}
onClick={() => {
setTouched(true);
setConsoleOpen((v) => !v);
}}
className="inline-flex h-6 cursor-pointer items-center gap-1.5 rounded-md px-1.5 text-[11px] font-medium outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
>
<SquareTerminal className="size-3.5 text-muted-foreground" aria-hidden />
Console
<ChevronDown className={cn("size-3 text-muted-foreground transition-transform duration-300", !consoleOpen && "-rotate-90")} aria-hidden />
</button>
<div className="ml-auto flex gap-0.5" role="tablist" aria-label="Stream">
{(["all", "stdout", "stderr"] as const).map((f) => {
const on = view.filter === f;
return (
<button
key={f}
type="button"
role="tab"
aria-selected={on}
onClick={() => {
setConsoleOpen(true);
set({ filter: f });
}}
className={cn(
"relative inline-flex h-6 cursor-pointer items-center gap-1 rounded-md px-1.5 font-mono text-[10px] outline-none focus-visible:ring-2 focus-visible:ring-ring",
on ? "text-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
{on && <motion.span layoutId={`${uid}-f`} className="absolute inset-0 rounded-md bg-muted" transition={{ type: "spring", stiffness: 500, damping: 36 }} />}
<span className="relative">{f}</span>
{f === "stderr" && errors > 0 && (
<motion.span
key={errors}
initial={reduce ? false : { scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 600, damping: 16 }}
className="relative grid size-3.5 place-items-center rounded-full bg-red-500 text-[9px] font-semibold text-white"
>
{errors}
</motion.span>
)}
</button>
);
})}
</div>
</div>
<AnimatePresence initial={false}>
{consoleOpen && (
<motion.div
initial={reduce ? false : { height: 0 }}
animate={{ height: 84 }}
exit={reduce ? undefined : { height: 0 }}
transition={{ duration: 0.3, ease: EASE }}
className="overflow-hidden bg-muted/40"
>
<ol className="flex h-[84px] flex-col justify-end px-3 py-1.5 font-mono text-[10px] leading-[18px]" aria-live="polite">
<AnimatePresence initial={false} mode="popLayout">
{shown.map((l) => (
<motion.li
key={`${l.time}-${l.text}`}
layout={!reduce}
initial={reduce ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? undefined : { opacity: 0 }}
transition={{ duration: 0.25, ease: EASE }}
className={cn("flex gap-2 truncate", l.stream === "stderr" && "-mx-1.5 rounded bg-red-500/10 px-1.5 text-red-700 dark:text-red-300")}
>
{l.time && <span className="shrink-0 text-muted-foreground/70">{l.time}</span>}
<span className="truncate">{l.text}</span>
</motion.li>
))}
</AnimatePresence>
</ol>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
);
}
function CheckoutPage({ compact, price, reduce }: { compact: boolean; price: number; reduce: boolean }) {
return (
<div className={cn("flex h-full gap-2.5 p-3 pt-4", compact ? "flex-col" : "flex-row")}>
<div className="min-w-0 flex-1">
<p className="text-[9px] font-medium tracking-wide text-muted-foreground uppercase">Checkout</p>
<p className="mt-0.5 text-[12px] leading-tight font-semibold">Pro plan</p>
<div className="mt-1.5 flex items-baseline gap-1">
<motion.span
key={price}
initial={reduce ? false : { y: -10, opacity: 0, backgroundColor: "rgb(250 204 21 / 0.6)" }}
animate={{ y: 0, opacity: 1, backgroundColor: "rgb(250 204 21 / 0)" }}
transition={{ duration: 0.9, ease: EASE }}
className="rounded px-0.5 font-mono text-[15px] font-semibold"
>
${price}
</motion.span>
<span className="text-[9px] text-muted-foreground">/ month</span>
</div>
{!compact && (
<ul className="mt-2 space-y-1">
{[70, 56, 64].map((w, i) => (
<li key={i} className="flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-emerald-500" />
<span className="h-1.5 rounded bg-foreground/10" style={{ width: `${w}%` }} />
</li>
))}
</ul>
)}
</div>
<div className={cn("flex flex-col gap-1.5 rounded-lg border bg-muted/40 p-2", compact ? "" : "w-[46%]")}>
<span className="h-5 rounded border bg-background" />
{!compact && <span className="h-5 rounded border bg-background" />}
<span className="mt-auto grid h-6 place-items-center rounded bg-foreground text-[9px] font-medium text-background">Pay ${price}</span>
</div>
</div>
);
}
export default function SandboxPreviewDemo() {
return (
<div className="flex h-[380px] w-full items-start justify-center pt-2">
<SandboxPreview autoPlay />
</div>
);
}