nextui.dev
Home
Docs
Saved
Pricing
Components37
  • Forms & Inputs7
  • Cards13
  • Buttons, Badges & Avatars8
  • Feedback & Display6
  • Effects3
Agents46
  • Input & Context9
  • Thinking & Status6
  • Tools & Execution7
  • Human-in-the-Loop & Safety7
  • Multi-Agent & Workflows4
  • Output & Artifacts8
  • Voice & Memory5
Blocks20
  • Agent chatNew
  • Pill heroNew
  • Grid hero
  • Moodboard generatorNew
  • Waitlist heroNew
  • Intro bannerNew
  • Creative home heroNew
  • Chat heroNew
  • App shelfNew
  • Usage card
  • Design resultsNew
  • Pricing tiers
  • Dashboard home
  • The shiftNew
  • Launch perksNew
  • Testimonials
  • Quote
  • Team
  • Leads tableNew
  • Sign in
Pages16
  • ASCII workspace$49
  • Research agent$49
  • Support agent$49
  • Coding agent$49
  • CRM overviewNew
  • Marketing dashboardNew
  • Developer portfolio$49
  • Particle landing$49
  • Particle story$49
  • Statement converter$49
  • AI photo studio$49
  • Group card launch$49
  • Month-end storyNew
  • Task HubNew
  • Global MediaNew
  • Hexora ecosystem$49
Themes9
  • Default
  • Ember
  • Ocean
  • Forest
  • Grape
  • Rose
  • Amber
  • Clay
  • Brutal
UpgradeDark modeLight modeFeedbackGitHub
nextui.dev

nextui components, blocks and themes you install with one command.

Product

  • Components
  • Blocks
  • Themes
  • Pricing

Resources

  • Docs
  • Roadmap
  • GitHub

Legal

  • License
  • Privacy
  • Terms
  • Refunds

nextUI

[ 200 OK ] · nextui.dev

© 2026 nextui.dev • All rights reserved.

UpgradeDark modeLight modeFeedbackGitHub
ComponentsAI & AgentsSandbox preview
Copy promptCopy prompt

Customize

Local dev server port

3000

Previewed route path

Loop a responsive viewport and reload demo

JSX · copied with Copy prompt

<SandboxPreview autoPlay />

Sandbox previewNew

Embedded dev-server port preview frame for a coding agent: live localhost address bar, Desktop/Mobile viewport toggle, reload button and HMR console drawer.

Installation

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}

Usage

1"use client";
2
3import { SandboxPreview } from "@/components/nextui/sandbox-preview";
4
5export function DevServerFrame() {
6 return <SandboxPreview port={3000} route="/checkout" />;
7}

API reference

Edit props live in the preview above, or see all 7 props
PropTypeDefaultDescription
portnumber3000Local dev server port
routestring"/checkout"Previewed route path
logs(string | SandboxLog)[]–Dev server stdout/HMR lines shown in the bottom console drawer
childrenReactNode–Your own preview content; replaces the scripted checkout page
onReload() => void–Called when Reload is pressed
autoPlaybooleanfalseLoop a responsive viewport and reload demo
classNamestring–Extra classes on the wrapper

Related

All components

Chat inputNew

Task checklistNew

Free

ReasoningNew

components/nextui/sandbox-preview.tsx
"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>
  );
}
Sign in