# Exploded UI

> A product screenshot that comes apart into its layers as you scroll, explains each one, then clicks back together. The layers are real HTML, so text stays sharp and follows your theme.

An animated React component from KeyframeUI, installed as source with the shadcn CLI. Built with React, Tailwind CSS and Motion.

- Docs and live preview: https://keyframeui.com/components/exploded-ui
- Tags: Scroll, 3D, Showcase
- npm dependencies: motion, clsx, tailwind-merge

## Installation

Register the @keyframeui registry in `components.json` once per project:

```json
{
  "registries": {
    "@keyframeui": "https://keyframeui.com/r/{name}.json"
  }
}
```

Then add the component. It lands in `components/ui/exploded-ui.tsx`, and the CLI also adds `lib/utils.ts` (the `cn` helper) and installs the npm dependencies:

```bash
npx shadcn@latest add @keyframeui/exploded-ui
```

Or install by URL, without registering the namespace:

```bash
npx shadcn@latest add https://keyframeui.com/r/exploded-ui.json
```

## Usage

The main demo shows typical usage.

### Dashboard

A dashboard comes apart into its shell, sidebar, live cards, command menu and a teammate's cursor, walks through each one, then lands back as the screenshot it started as. Hover a layer once it's open, or click a label to jump to it.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/exploded-ui-demo
```

```tsx title="components/exploded-ui-demo.tsx"
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { ExplodedUI } from "@/components/ui/exploded-ui";

// Everything inside the screenshot is sized off the plane's width (Tailwind's
// spacing, text and radius scales are set in container units), so it scales
// like an image while staying real, theme-aware HTML.
const screen =
  "[--spacing:0.4cqw] [--text-xs:1.2cqw] [--text-sm:1.4cqw] [--text-base:1.6cqw] [--text-lg:1.9cqw] [--text-2xl:2.6cqw] [--radius-md:0.6cqw] [--radius-lg:0.8cqw] [--radius-xl:1.2cqw]";

// For a full-page section, drop the scroll box, `containerRef` and
// `stageClassName`: the stage fills the window and sticks while the page scrolls.
export function ExplodedUIDemo() {
  const scrollRef = React.useRef<HTMLDivElement>(null);

  return (
    <div className="relative w-full overflow-hidden rounded-xl border border-zinc-200 bg-zinc-50 text-zinc-950 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-50">
      <div ref={scrollRef} className="relative h-[var(--demo-height,36rem)] overflow-y-auto overscroll-contain">
        <header className="mx-auto max-w-2xl px-6 pt-16 pb-4 text-center sm:pt-20">
          <p className="text-sm font-medium text-indigo-600 dark:text-indigo-400">Inside Northwind</p>
          <h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
            Everything your revenue team needs, in one window
          </h2>
          <p className="mt-4 text-zinc-600 dark:text-zinc-400">Scroll to take it apart.</p>
        </header>

        <ExplodedUI
          walkthrough
          containerRef={scrollRef}
          stageClassName="h-[var(--demo-height,36rem)]"
          className={cn("aspect-[16/10] w-full max-w-3xl", screen)}
        >
          <ExplodedUI.Layer label="App shell" description="Navigation, search and settings.">
            <Shell />
          </ExplodedUI.Layer>
          <ExplodedUI.Layer
            label="Sidebar"
            description="Every workspace in one list."
            className="top-[5.6%] bottom-0 left-0 w-[20%]"
          >
            <Sidebar />
          </ExplodedUI.Layer>
          <ExplodedUI.Layer
            label="Live cards"
            description="Metrics update in real time."
            className="top-[17.5%] left-[23%] w-[74%]"
          >
            <MetricCards />
          </ExplodedUI.Layer>
          <ExplodedUI.Layer
            label="Command menu"
            description="Press ⌘K to do anything."
            className="top-[36%] left-[40%] w-[38%]"
          >
            <CommandMenu />
          </ExplodedUI.Layer>
          <ExplodedUI.Layer
            label="Live cursors"
            description="See where your team is working."
            bare
            className="top-[58%] left-[66%] w-[11%]"
          >
            <Cursor />
          </ExplodedUI.Layer>
        </ExplodedUI>

        <section className="mx-auto grid max-w-3xl gap-8 px-6 py-16 sm:grid-cols-3">
          {[
            ["Built for speed", "Every screen loads in under 100ms, even with a million rows."],
            ["Keyboard first", "Anything you can click, you can do from the command menu."],
            ["Live by default", "Numbers, comments and cursors update the moment they change."],
          ].map(([title, body]) => (
            <div key={title}>
              <h3 className="font-medium">{title}</h3>
              <p className="mt-2 text-sm leading-relaxed text-pretty text-zinc-600 dark:text-zinc-400">{body}</p>
            </div>
          ))}
        </section>
      </div>
    </div>
  );
}

const nav = ["Overview", "Pipeline", "Accounts", "Reports"];
const workspaces = [
  ["Acme Corp", "bg-indigo-500"],
  ["Globex", "bg-emerald-500"],
  ["Initech", "bg-amber-500"],
  ["Umbrella", "bg-rose-500"],
];

function Shell() {
  return (
    <div className="flex size-full flex-col bg-white text-xs text-zinc-900 dark:bg-zinc-900 dark:text-zinc-100">
      <div className="flex h-[5.6%] shrink-0 items-center gap-1.5 border-b border-zinc-950/5 px-3 dark:border-white/5">
        {[0, 1, 2].map((dot) => (
          <span key={dot} className="size-2.5 rounded-full bg-zinc-200 dark:bg-zinc-700" />
        ))}
        <span className="mx-auto rounded-md bg-zinc-100 px-3 py-0.5 text-zinc-500 dark:bg-zinc-800">
          app.northwind.io/overview
        </span>
      </div>
      <div className="flex min-h-0 flex-1">
        {/* The sidebar's slot */}
        <div className="w-[20%] shrink-0 bg-zinc-50 dark:bg-zinc-900" />
        <div className="flex min-w-0 flex-1 flex-col gap-4 bg-zinc-50 p-5 dark:bg-zinc-950">
          <div className="flex items-center justify-between">
            <span className="text-lg font-semibold tracking-tight">Overview</span>
            <div className="flex items-center gap-2 text-zinc-500">
              <span className="flex w-40 items-center gap-2 rounded-md bg-white px-2.5 py-1.5 ring-1 ring-zinc-950/5 dark:bg-zinc-900 dark:ring-white/10">
                <SearchIcon />
                Search
                <kbd className="ml-auto font-sans text-zinc-400">⌘K</kbd>
              </span>
              <span className="size-7 rounded-full bg-linear-to-br from-amber-300 to-rose-400" />
            </div>
          </div>
          {/* The cards' slot */}
          <div className="h-[22%] shrink-0" />
          <div className="grid min-h-0 flex-1 grid-cols-[1.6fr_1fr] gap-4">
            <div className="flex flex-col rounded-lg bg-white p-4 ring-1 ring-zinc-950/5 dark:bg-zinc-900 dark:ring-white/5">
              <div className="flex justify-between text-zinc-500">
                <span>Revenue</span>
                <span>Last 12 weeks</span>
              </div>
              <div className="mt-4 flex min-h-0 flex-1 items-end gap-2">
                {[38, 52, 44, 61, 57, 70, 64, 78, 72, 86, 81, 94].map((h, i) => (
                  <span
                    key={i}
                    className={cn("flex-1 rounded-sm", i === 11 ? "bg-indigo-500" : "bg-indigo-500/20 dark:bg-indigo-400/25")}
                    style={{ height: `${h}%` }}
                  />
                ))}
              </div>
            </div>
            <div className="rounded-lg bg-white p-4 ring-1 ring-zinc-950/5 dark:bg-zinc-900 dark:ring-white/5">
              <span className="text-zinc-500">Top accounts</span>
              <ul className="mt-3 space-y-3">
                {workspaces.map(([name, color], i) => (
                  <li key={name} className="flex items-center gap-2">
                    <span className={cn("size-5 rounded-md", color)} />
                    <span className="font-medium">{name}</span>
                    <span className="ml-auto text-zinc-500 tabular-nums">${[48, 36, 22, 17][i]}k</span>
                  </li>
                ))}
              </ul>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

function Sidebar() {
  return (
    <div className="flex size-full flex-col gap-0.5 border-r border-zinc-950/5 bg-white p-3 text-xs text-zinc-900 dark:border-white/5 dark:bg-zinc-900 dark:text-zinc-100">
      <div className="mb-4 flex items-center gap-2 px-2 pt-1 text-sm font-semibold">
        <span className="size-5 rounded-md bg-indigo-600" />
        Northwind
      </div>
      {nav.map((item, i) => (
        <span
          key={item}
          className={cn("rounded-md px-2 py-1.5", i === 0 ? "bg-zinc-100 font-medium dark:bg-zinc-800" : "text-zinc-500")}
        >
          {item}
        </span>
      ))}
      <span className="mt-5 mb-1 px-2 text-zinc-400">Workspaces</span>
      {workspaces.map(([name, color]) => (
        <span key={name} className="flex items-center gap-2 px-2 py-1.5 text-zinc-500">
          <span className={cn("size-2 rounded-full", color)} />
          {name}
        </span>
      ))}
    </div>
  );
}

const metrics = [
  { label: "Revenue", value: "$48.2k", delta: "+12.4%", points: "0,30 15,26 30,28 45,18 60,20 75,10 90,12 100,4" },
  { label: "Active users", value: "2,931", delta: "+8.1%", points: "0,24 15,28 30,20 45,22 60,14 75,16 90,8 100,10" },
  { label: "Conversion", value: "3.8%", delta: "+0.6%", points: "0,28 15,22 30,24 45,20 60,22 75,14 90,16 100,8" },
];

function MetricCards() {
  return (
    <div className="grid grid-cols-3 gap-4 bg-zinc-50 text-xs text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100">
      {metrics.map((metric) => (
        <div key={metric.label} className="rounded-lg bg-white p-4 ring-1 ring-zinc-950/5 dark:bg-zinc-900 dark:ring-white/5">
          <div className="flex items-center justify-between text-zinc-500">
            {metric.label}
            <span className="flex items-center gap-1 text-emerald-600 dark:text-emerald-400">
              <span className="size-1.5 rounded-full bg-current" />
              Live
            </span>
          </div>
          <div className="mt-2 flex items-end justify-between gap-2">
            <span className="text-2xl font-semibold tracking-tight tabular-nums">{metric.value}</span>
            <svg viewBox="0 0 100 34" preserveAspectRatio="none" aria-hidden className="h-8 w-[45%] overflow-visible">
              <polyline
                points={metric.points}
                fill="none"
                stroke="currentColor"
                strokeWidth="2"
                vectorEffect="non-scaling-stroke"
                className="text-indigo-500"
              />
            </svg>
          </div>
          <span className="mt-1 block text-emerald-600 tabular-nums dark:text-emerald-400">{metric.delta} this week</span>
        </div>
      ))}
    </div>
  );
}

const commands = [
  { label: "Create invoice", keys: "I" },
  { label: "Invite teammate", keys: "T" },
  { label: "Export report", keys: "E" },
  { label: "Switch workspace", keys: "W" },
];

function CommandMenu() {
  return (
    <div className="overflow-hidden rounded-lg bg-white text-xs text-zinc-900 shadow-2xl ring-1 shadow-zinc-950/20 ring-zinc-950/10 dark:bg-zinc-800 dark:text-zinc-100 dark:ring-white/10">
      <div className="flex items-center gap-2 border-b border-zinc-950/5 px-3 py-2.5 text-sm text-zinc-500 dark:border-white/5">
        <SearchIcon />
        Type a command…
      </div>
      <div className="p-1.5">
        <span className="block px-2 py-1 text-zinc-400">Suggestions</span>
        {commands.map((command, i) => (
          <span
            key={command.label}
            className={cn(
              "flex items-center justify-between rounded-md px-2 py-1.5",
              i === 1 && "bg-indigo-500/10 text-indigo-700 dark:text-indigo-300",
            )}
          >
            {command.label}
            <kbd className="rounded bg-zinc-100 px-1.5 font-sans text-zinc-500 dark:bg-zinc-700 dark:text-zinc-300">
              {command.keys}
            </kbd>
          </span>
        ))}
      </div>
    </div>
  );
}

function Cursor() {
  return (
    <div className="flex flex-col items-start text-xs">
      <svg viewBox="0 0 16 16" aria-hidden className="size-5 drop-shadow-md">
        <path d="M2 1.5 14 7.2l-5.2 1.4L6.6 14Z" className="fill-rose-500 stroke-white" strokeWidth="1.2" strokeLinejoin="round" />
      </svg>
      <span className="mt-0.5 ml-3 rounded-full bg-rose-500 px-2 py-0.5 font-medium text-white shadow-md">Maya</span>
    </div>
  );
}

function SearchIcon() {
  return (
    <svg viewBox="0 0 16 16" aria-hidden className="size-3.5 shrink-0 fill-none stroke-current" strokeWidth="1.6">
      <circle cx="7" cy="7" r="4.5" />
      <path d="m10.5 10.5 3 3" strokeLinecap="round" />
    </svg>
  );
}
```

## Examples

Finished sections built with Exploded UI. Each one installs whole, with the component included.

### Architecture

The layers don't have to be UI. Four plain planes, from your app down to the database, that stay open at the end. Each carries its name on its front edge, the strip that stays in view.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/exploded-ui-architecture-demo
```

```tsx title="components/exploded-ui-architecture-demo.tsx"
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { ExplodedUI } from "@/components/ui/exploded-ui";

// Sized off the plane's width, so each plane scales like an image.
const screen =
  "[--spacing:0.4cqw] [--text-xs:1.2cqw] [--text-sm:1.4cqw] [--text-base:1.6cqw] [--text-lg:1.9cqw] [--text-2xl:2.6cqw] [--radius-md:0.6cqw] [--radius-lg:0.8cqw] [--radius-xl:1.2cqw]";

// The planes don't have to be UI. Each tier carries its name on its front
// edge, the strip that stays in view once the stack is open.
export function ExplodedUIArchitectureDemo() {
  const scrollRef = React.useRef<HTMLDivElement>(null);

  return (
    <div className="relative w-full overflow-hidden rounded-xl border border-zinc-200 bg-white text-zinc-950 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-50">
      <div ref={scrollRef} className="relative h-[var(--demo-height,36rem)] overflow-y-auto overscroll-contain">
        <header className="mx-auto max-w-2xl px-6 pt-16 pb-4 text-center sm:pt-20">
          <p className="font-mono text-xs tracking-wide text-zinc-500 uppercase">How it works</p>
          <h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
            One request, four layers, 40 milliseconds
          </h2>
        </header>

        <ExplodedUI
          walkthrough
          end="exploded"
          spread={0.15}
          containerRef={scrollRef}
          stageClassName="h-[var(--demo-height,36rem)]"
          className={cn("aspect-[16/10] w-full max-w-2xl", screen)}
        >
          <ExplodedUI.Layer label="Database" description="Postgres with read replicas in three regions.">
            <Database />
          </ExplodedUI.Layer>
          <ExplodedUI.Layer label="Edge network" description="Requests land at the nearest of 300 cities.">
            <Edge />
          </ExplodedUI.Layer>
          <ExplodedUI.Layer label="SDK" description="Typed, retried and cached for you.">
            <Sdk />
          </ExplodedUI.Layer>
          <ExplodedUI.Layer label="Your app" description="Calls one function. That's the whole integration.">
            <App />
          </ExplodedUI.Layer>
        </ExplodedUI>

        <section className="mx-auto max-w-xl px-6 py-16 text-center">
          <p className="text-zinc-600 dark:text-zinc-400">
            Read the <a href="#docs" className="font-medium text-zinc-950 underline decoration-zinc-300 underline-offset-4 dark:text-zinc-50 dark:decoration-zinc-700">architecture guide</a> for the details.
          </p>
        </section>
      </div>
    </div>
  );
}

function Plane({
  name,
  meta,
  className,
  children,
}: {
  name: string;
  meta: string;
  className?: string;
  children: React.ReactNode;
}) {
  return (
    <div className={cn("relative flex size-full flex-col p-6 text-xs", className)}>
      <div className="min-h-0 flex-1">{children}</div>
      <div className="mt-4 flex items-baseline gap-3">
        <span className="text-2xl font-semibold tracking-tight">{name}</span>
        <span className="font-mono opacity-60">{meta}</span>
      </div>
    </div>
  );
}

const rows = [
  ["usr_8f2a", "maya@northwind.io", "Team", "2m ago"],
  ["usr_31cd", "jonas@globex.com", "Pro", "14m ago"],
  ["usr_77be", "ada@initech.dev", "Team", "1h ago"],
  ["usr_02f9", "lin@umbrella.co", "Free", "3h ago"],
];

function Database() {
  return (
    <Plane
      name="Database"
      meta="postgres 16 · 3 replicas"
      className="bg-emerald-50 text-emerald-950 dark:bg-emerald-950 dark:text-emerald-50"
    >
      <div className="overflow-hidden rounded-lg bg-white/70 font-mono ring-1 ring-emerald-900/10 dark:bg-emerald-900/30 dark:ring-emerald-100/10">
        {rows.map((row, i) => (
          <div
            key={row[0]}
            className={cn("grid grid-cols-[1fr_2fr_1fr_1fr] gap-4 px-4 py-2.5", i > 0 && "border-t border-emerald-900/10 dark:border-emerald-100/10")}
          >
            {row.map((cell) => (
              <span key={cell} className="truncate">
                {cell}
              </span>
            ))}
          </div>
        ))}
      </div>
    </Plane>
  );
}

// Points of presence, as percentages of the map.
const pops = [
  [18, 38],
  [27, 62],
  [47, 30],
  [52, 46],
  [63, 58],
  [78, 36],
  [84, 70],
];

function Edge() {
  return (
    <Plane
      name="Edge network"
      meta="300 cities · p50 12ms"
      className="bg-indigo-50 text-indigo-950 dark:bg-indigo-950 dark:text-indigo-50"
    >
      <div className="relative size-full">
        <svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden className="absolute inset-0 size-full overflow-visible">
          {pops.map(([x, y], i) =>
            i === 3 ? null : (
              <path
                key={i}
                d={`M ${pops[3][0]} ${pops[3][1]} Q ${(pops[3][0] + x) / 2} ${Math.min(pops[3][1], y) - 18} ${x} ${y}`}
                fill="none"
                stroke="currentColor"
                strokeOpacity="0.35"
                strokeWidth="1.2"
                strokeDasharray="2 3"
                vectorEffect="non-scaling-stroke"
              />
            ),
          )}
        </svg>
        {pops.map(([x, y]) => (
          <span
            key={`${x}-${y}`}
            className="absolute size-3 -translate-1/2 rounded-full bg-indigo-500 ring-4 ring-indigo-500/20"
            style={{ left: `${x}%`, top: `${y}%` }}
          />
        ))}
      </div>
    </Plane>
  );
}

function Sdk() {
  return (
    <Plane name="SDK" meta="@acme/sdk · 9kb" className="bg-zinc-900 text-zinc-100">
      <pre className="font-mono text-sm leading-relaxed">
        <span className="text-fuchsia-300">import</span> {"{ createClient }"} <span className="text-fuchsia-300">from</span>{" "}
        <span className="text-emerald-300">&quot;@acme/sdk&quot;</span>;{"\n\n"}
        <span className="text-fuchsia-300">const</span> acme = <span className="text-sky-300">createClient</span>({"{ region: "}
        <span className="text-emerald-300">&quot;auto&quot;</span>
        {" }"});{"\n"}
        <span className="text-fuchsia-300">const</span> user = <span className="text-fuchsia-300">await</span> acme.users.
        <span className="text-sky-300">get</span>(id);
      </pre>
    </Plane>
  );
}

const projects = [
  { name: "Storefront", status: "Ready", tone: "bg-emerald-500", when: "2m ago" },
  { name: "Admin", status: "Building", tone: "bg-amber-500", when: "just now" },
  { name: "Docs", status: "Ready", tone: "bg-emerald-500", when: "1h ago" },
  { name: "Marketing", status: "Ready", tone: "bg-emerald-500", when: "3h ago" },
  { name: "Mobile API", status: "Ready", tone: "bg-emerald-500", when: "1d ago" },
  { name: "Billing", status: "Queued", tone: "bg-zinc-400", when: "1d ago" },
];

function App() {
  return (
    <Plane name="Your app" meta="next.js · react" className="bg-white text-zinc-950 dark:bg-zinc-800 dark:text-zinc-50">
      <div className="flex items-center gap-6 text-zinc-500 dark:text-zinc-400">
        <span className="flex items-center gap-2 text-sm font-semibold text-zinc-950 dark:text-zinc-50">
          <span className="size-5 rounded-md bg-zinc-950 dark:bg-white" />
          Northwind
        </span>
        <span className="text-zinc-950 dark:text-zinc-50">Projects</span>
        <span>Usage</span>
        <span>Settings</span>
        <span className="ml-auto size-7 rounded-full bg-linear-to-br from-amber-300 to-rose-400" />
      </div>
      <div className="mt-6 flex items-center justify-between">
        <span className="text-lg font-semibold tracking-tight">Projects</span>
        <span className="rounded-md bg-zinc-950 px-3 py-1.5 font-medium text-white dark:bg-white dark:text-zinc-950">
          New project
        </span>
      </div>
      <div className="mt-4 grid grid-cols-3 gap-3">
        {projects.map((project) => (
          <div key={project.name} className="rounded-lg bg-zinc-50 p-3 ring-1 ring-zinc-950/5 dark:bg-zinc-900 dark:ring-white/10">
            <span className="mb-3 block h-12 rounded-md bg-linear-to-br from-zinc-200 to-zinc-100 dark:from-zinc-700 dark:to-zinc-800" />
            <span className="font-medium">{project.name}</span>
            <span className="mt-2 flex items-center gap-1.5 text-zinc-500 dark:text-zinc-400">
              <span className={cn("size-1.5 rounded-full", project.tone)} />
              {project.status} · {project.when}
            </span>
          </div>
        ))}
      </div>
    </Plane>
  );
}
```

### Design System

A page lifts off the components it's built from, which lift off the tokens under them. Numbered labels, with the active layer's description under the stack.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/exploded-ui-design-system-demo
```

```tsx title="components/exploded-ui-design-system-demo.tsx"
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { ExplodedUI } from "@/components/ui/exploded-ui";

// Sized off the plane's width, so each layer scales like an image.
const screen =
  "[--spacing:0.4cqw] [--text-xs:1.2cqw] [--text-sm:1.4cqw] [--text-base:1.6cqw] [--text-lg:1.9cqw] [--text-2xl:2.6cqw] [--text-4xl:4cqw] [--radius-md:0.6cqw] [--radius-lg:0.8cqw] [--radius-xl:1.2cqw]";

// The page on top is built from the components under it, which are built from
// the tokens at the bottom: the same three colors run through all of them.
export function ExplodedUIDesignSystemDemo() {
  const scrollRef = React.useRef<HTMLDivElement>(null);

  return (
    <div className="relative w-full overflow-hidden rounded-xl border border-zinc-200 bg-stone-100 text-zinc-950 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-50">
      <div ref={scrollRef} className="relative h-[var(--demo-height,36rem)] overflow-y-auto overscroll-contain">
        <header className="mx-auto max-w-2xl px-6 pt-16 pb-4 text-center sm:pt-20">
          <p className="text-sm font-medium text-orange-600 dark:text-orange-400">Fieldwork Design System</p>
          <h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
            Every page starts as three decisions
          </h2>
        </header>

        <ExplodedUI
          walkthrough
          labels="numbered"
          spread={0.16}
          containerRef={scrollRef}
          stageClassName="h-[var(--demo-height,36rem)]"
          className={cn("aspect-[16/10] w-full max-w-2xl", screen)}
        >
          <ExplodedUI.Layer label="Tokens" description="Color, type and spacing, decided once.">
            <Tokens />
          </ExplodedUI.Layer>
          <ExplodedUI.Layer
            label="Components"
            description="Buttons, fields and toggles built from the tokens."
            className="inset-[7%]"
          >
            <Components />
          </ExplodedUI.Layer>
          <ExplodedUI.Layer label="Pages" description="Composed from components. Nothing one-off.">
            <Page />
          </ExplodedUI.Layer>
        </ExplodedUI>

        <section className="mx-auto max-w-xl px-6 py-16 text-center text-zinc-600 dark:text-zinc-400">
          120 components, 2 themes, 0 hex codes in product code.
        </section>
      </div>
    </div>
  );
}

const palette = [
  ["Ink", ["bg-zinc-50", "bg-zinc-200", "bg-zinc-400", "bg-zinc-700", "bg-zinc-950"]],
  ["Clay", ["bg-orange-50", "bg-orange-200", "bg-orange-400", "bg-orange-600", "bg-orange-800"]],
  ["Moss", ["bg-lime-50", "bg-lime-200", "bg-lime-400", "bg-lime-600", "bg-lime-800"]],
] as const;

function Tokens() {
  return (
    <div className="grid size-full grid-cols-[1.3fr_1fr] gap-6 bg-white p-6 text-xs text-zinc-500 dark:bg-zinc-900 dark:text-zinc-400">
      <div className="flex flex-col gap-4">
        <span className="font-mono">color</span>
        {palette.map(([name, swatches]) => (
          <div key={name} className="flex items-center gap-3">
            <span className="w-10">{name}</span>
            <div className="flex flex-1 gap-1.5">
              {swatches.map((swatch) => (
                <span key={swatch} className={cn("aspect-square flex-1 rounded-md ring-1 ring-zinc-950/5", swatch)} />
              ))}
            </div>
          </div>
        ))}
      </div>
      <div className="flex flex-col gap-3">
        <span className="font-mono">type</span>
        <span className="text-4xl leading-none font-semibold tracking-tight text-zinc-950 dark:text-zinc-50">Aa</span>
        {[
          ["display", "40 / 44"],
          ["title", "24 / 30"],
          ["body", "16 / 24"],
        ].map(([name, size]) => (
          <span key={name} className="flex justify-between border-b border-zinc-950/5 pb-1.5 dark:border-white/10">
            {name}
            <span className="font-mono">{size}</span>
          </span>
        ))}
        <span className="mt-2 font-mono">space</span>
        <div className="flex items-end gap-1.5">
          {[1, 2, 3, 4, 6, 8].map((n) => (
            <span key={n} className="rounded-sm bg-orange-400/70" style={{ width: `${n * 0.5}cqw`, height: `${n * 0.5}cqw` }} />
          ))}
        </div>
      </div>
    </div>
  );
}

function Components() {
  return (
    <div className="grid size-full grid-cols-2 content-center gap-x-8 gap-y-6 bg-stone-50 p-8 text-xs dark:bg-zinc-800">
      <div className="flex flex-wrap items-center gap-2">
        <span className="rounded-lg bg-zinc-950 px-4 py-2 font-medium text-white dark:bg-white dark:text-zinc-950">Primary</span>
        <span className="rounded-lg px-4 py-2 font-medium ring-1 ring-zinc-950/15 ring-inset dark:ring-white/20">Secondary</span>
        <span className="rounded-lg bg-orange-600 px-4 py-2 font-medium text-white">Accent</span>
      </div>
      <div className="flex items-center gap-3">
        <span className="flex h-6 w-11 items-center rounded-full bg-lime-600 p-0.5">
          <span className="ml-auto size-5 rounded-full bg-white shadow-sm" />
        </span>
        <span className="flex size-5 items-center justify-center rounded-md bg-zinc-950 text-white dark:bg-white dark:text-zinc-950">
          <svg viewBox="0 0 12 12" aria-hidden className="size-3 fill-none stroke-current" strokeWidth="2">
            <path d="m2.5 6.5 2.5 2.5 4.5-6" />
          </svg>
        </span>
        <span className="rounded-full bg-lime-100 px-2.5 py-1 font-medium text-lime-800 dark:bg-lime-900/50 dark:text-lime-200">Shipped</span>
      </div>
      <div className="flex flex-col gap-1.5">
        <span className="font-medium">Email</span>
        <span className="rounded-lg bg-white px-3 py-2 text-zinc-400 ring-1 ring-zinc-950/10 dark:bg-zinc-900 dark:ring-white/10">you@fieldwork.co</span>
      </div>
      <div className="flex -space-x-2">
        {["bg-orange-300", "bg-lime-300", "bg-zinc-300", "bg-orange-500"].map((color) => (
          <span key={color} className={cn("size-8 rounded-full ring-2 ring-stone-50 dark:ring-zinc-800", color)} />
        ))}
      </div>
    </div>
  );
}

function Page() {
  return (
    <div className="flex size-full flex-col bg-orange-50 p-6 text-xs dark:bg-zinc-900">
      <div className="flex items-center justify-between">
        <span className="flex items-center gap-2 text-sm font-semibold">
          <span className="size-5 rounded-full bg-orange-600" />
          Fieldwork
        </span>
        <div className="flex items-center gap-5 text-zinc-600 dark:text-zinc-400">
          <span>Product</span>
          <span>Pricing</span>
          <span className="rounded-lg bg-zinc-950 px-3 py-1.5 font-medium text-white dark:bg-white dark:text-zinc-950">Sign up</span>
        </div>
      </div>
      <div className="mt-[8%] grid flex-1 grid-cols-[1.2fr_1fr] gap-6">
        <div>
          <span className="rounded-full bg-lime-100 px-2.5 py-1 font-medium text-lime-800 dark:bg-lime-900/50 dark:text-lime-200">Shipped: v4</span>
          <p className="mt-4 text-4xl leading-[1.05] font-semibold tracking-tight text-balance">Field notes for teams that build</p>
          <div className="mt-6 flex gap-2">
            <span className="rounded-lg bg-orange-600 px-4 py-2 font-medium text-white">Start free</span>
            <span className="rounded-lg px-4 py-2 font-medium ring-1 ring-zinc-950/15 ring-inset dark:ring-white/20">Book a demo</span>
          </div>
        </div>
        <div className="flex flex-col gap-3 self-start rounded-xl bg-white p-5 shadow-xl ring-1 shadow-orange-950/10 ring-zinc-950/5 dark:bg-zinc-800 dark:ring-white/10">
          <span className="font-medium">Join the beta</span>
          <span className="rounded-lg bg-white px-3 py-2 text-zinc-400 ring-1 ring-zinc-950/10 dark:bg-zinc-900 dark:ring-white/10">you@fieldwork.co</span>
          <span className="flex items-center gap-2 text-zinc-500">
            <span className="flex h-5 w-9 items-center rounded-full bg-lime-600 p-0.5">
              <span className="ml-auto size-4 rounded-full bg-white" />
            </span>
            Weekly digest
          </span>
          <span className="mt-2 rounded-lg bg-zinc-950 py-2 text-center font-medium text-white dark:bg-white dark:text-zinc-950">Request access</span>
        </div>
      </div>
    </div>
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` (required) | `React.ReactNode` |  | The layers, as `ExplodedUI.Layer`s. The first child is the bottom of the stack and each one after sits on top. In a Server Component, import and use `ExplodedUILayer` instead: the dot doesn't reach across to client components. |
| `walkthrough` | `boolean` | `false` | Once the stack is open, step through the labeled layers from the bottom up: the active one lifts a little further while the others fade back, and its label opens to show its description. Layers above it fade out to their outlines, so it's never covered. Click a label to scroll the walkthrough to it. |
| `end` | `"assembled" \| "exploded"` | `"assembled"` | `assembled` drops the layers back bottom first and lands the flat screenshot exactly where it started. `exploded` gives the walkthrough the rest of the scroll and leaves the stack open as the section scrolls away, for a "how it's built" section. |
| `labels` | `"side" \| "numbered" \| false` | `"side"` | `side` puts the labels in a column beside the stack with dotted leaders to their layers. `numbered` pins a number on each layer's front edge and lists the names under the stack. Side labels become numbered on stages narrower than 640px. |
| `tilt` | `{ back?: number; turn?: number }` | `{ back: 50, turn: -30 }` | The viewing angle once tilted, in degrees: the plane turns on the page by `turn`, then leans away by `back`. Narrow stages get 80% of it. |
| `spread` | `number` | `0.12` | The gap between neighboring layers once open, as a share of the plane's width, so it scales with the screenshot. Fewer layers can take more. |
| `scrollLength` | `number` | `3, or 4 with walkthrough` | How long the section stays stuck, in stage heights. The tilt, the explode and the return each take a fixed share of it; the walkthrough splits its share evenly between the layers. |
| `interactive` | `boolean` | `true` | While the stack is open, hovering a layer or its label (or focusing a label) lifts it like the walkthrough does, and moving the mouse turns the stack up to 4°. Without a walkthrough, clicking a label keeps its layer highlighted. Pointer tilt is mouse only. |
| `onLayerChange` | `(index: number \| null) => void` |  | Called with a layer's index, counted from the bottom, as the walkthrough reaches it, and with null as the walkthrough leaves the layers. |
| `containerRef` | `React.RefObject<HTMLElement \| null>` |  | Scroll container to track. Defaults to the window. The container must be positioned (e.g. `relative`). |
| `stageClassName` | `string` | `"h-dvh"` | Classes for the stage that sticks while the section scrolls. Set its height and background here. The labels are drawn on it, outside the plane. |
| `className` | `string` | `"aspect-[16/10] w-full max-w-5xl"` | Classes for the plane: the screenshot's box. Size it here. It's a container, so layer content can size itself in `cqw` units and scale like an image. Other props pass through to the outer section. |
| `Layer.label` | `React.ReactNode` |  | The layer's name, shown once it lifts off. Layers without one aren't labeled or visited by the walkthrough. |
| `Layer.description` | `React.ReactNode` |  | One line about the layer, shown under its label while it's active. |
| `Layer.className` | `string` | `"inset-0"` | Places and sizes the layer on the plane, like `left-0 top-0 h-full w-[22%]`. Without any position or size, it fills the plane. Each layer is a sheet with slightly rounded corners that clips its content; its edge and the light along its top fade in as it lifts off. |
| `Layer.bare` | `boolean` | `false` | Drops the sheet: no clipping, no edge, and no shadow cast or received. For pieces that float on their own, like a cursor. |

## Source

```tsx title="components/ui/exploded-ui.tsx"
"use client";

import * as React from "react";
import {
  animate,
  cancelFrame,
  cubicBezier,
  frame,
  motionValue,
  useScroll,
  useSpring,
  type MotionValue,
} from "motion/react";

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

/* ─────────────────────────────────────────────────────────
 * EXPLODED UI STORYBOARD   (driven by scroll progress, not time)
 *
 *     0%   the section sticks with the screenshot centered.
 *          It's plain, flat HTML: no 3D, pin-sharp
 *  15→35%  the plane tilts back and turns, still one piece,
 *          and shrinks to make room for the stack. A faint
 *          grid floor fades in under it
 *  32→55%  the layers lift off top first, each gap opening
 *          before the one beneath it has finished, so the
 *          stack peels apart in one motion. Each layer casts
 *          a shadow that softens as its gap widens. Leaders
 *          draw out from the layers, then the labels fade in
 *  56→85%  walkthrough: one layer at a time lifts a little
 *          further while the rest fade back, and its label
 *          opens to show its description. Layers above it
 *          fade out to their outlines, so it's never covered
 *  85→97%  the gaps close bottom first, the plane tilts flat
 *          and lands exactly where it started
 *    100%  the section unsticks and the page carries on
 *
 *  Scrolling up plays it backwards. end="exploded" gives the
 *  walkthrough the rest of the scroll and leaves the stack
 *  open. While it's open, hovering a layer or its label does
 *  what the walkthrough does, and the pointer turns the stack
 *  a few degrees.
 *
 *  Every frame is one pose: the plane's angle, scale and
 *  offset, and each layer's height. The layers are drawn
 *  from it in 3D, and the labels from the same pose projected
 *  flat, so they stay on their layers and never skew or blur.
 * ───────────────────────────────────────────────────────── */

const EXPLODE = {
  back: 50, // degrees the plane tilts away, by default
  turn: -30, // degrees it turns on the page before it tilts, by default
  spread: 0.12, // gap between layers, as a share of the plane's width
  scale: 0.85, // the largest the tilted plane gets, as a share of its size
  overlap: 0.55, // share of each gap's opening that overlaps the next one's
  lift: 0.35, // how much higher the active layer rises, in gaps
  // How much the other layers fade while one is active. Those above it are
  // in the way, so they fade out entirely and leave their outline.
  fade: { below: 0.65, above: 1 },
  pointer: 4, // degrees the pointer turns the open stack, either way
  perspective: 3, // perspective distance, in plane widths
  floor: 0.6, // how far the grid floor sits under the base, in gaps
  narrow: 640, // px: narrower stages get numbered labels and a gentler tilt
  gentle: 0.8, // share of the tilt kept on narrow stages
  ease: cubicBezier(0.65, 0, 0.35, 1), // each beat's pace
};

// Where each beat sits, as shares of the time the section is stuck.
const BEATS = {
  tilt: [0.15, 0.35],
  explode: [0.32, 0.55],
  tour: [0.56, 0.85],
  collapse: [0.85, 0.93],
  flatten: [0.9, 0.97],
} as const;

// With end="exploded", the walkthrough takes the rest of the scroll.
const OPEN_TOUR = [0.56, 0.95] as const;

const SCROLL_SPRING = { stiffness: 120, damping: 30, mass: 0.4 };
const POINTER_SPRING = { stiffness: 70, damping: 18, mass: 1 };
const LIFT_SPRING = { type: "spring", visualDuration: 0.45, bounce: 0.2 } as const;
const DIM_SPRING = { type: "spring", visualDuration: 0.35, bounce: 0 } as const;

const LABEL = {
  leader: 40, // px from the furthest anchor to the label column
  knee: 16, // px before the column where a leader bends toward its label
  gap: 10, // px kept between labels
  dim: 0.6, // how much the other labels fade while one is active
  pad: 20, // px the stack and labels keep from the stage's edges
};

// A layer fills the plane unless its classes place or size it.
const PLACED = /(?:^|\s|:)-?(?:inset|top|right|bottom|left|start|end|w|h|size)-/;

export interface ExplodedUILayerProps {
  /** Shown beside the layer once the stack is open. Layers without one aren't labeled or visited by the walkthrough. */
  label?: React.ReactNode;
  /** One line about the layer, shown while it's active. */
  description?: React.ReactNode;
  /** Places and sizes the layer on the plane, like `left-0 top-0 h-full w-[22%]`. Without any, it fills the plane. */
  className?: string;
  /** Drops the sheet: no clipping, edges or shadow. For floating pieces like a cursor. */
  bare?: boolean;
  children?: React.ReactNode;
}

/** One layer of the stack. Its place among its siblings sets its depth: the first is the bottom. */
export function ExplodedUILayer(props: ExplodedUILayerProps): React.ReactElement | null {
  void props; // ExplodedUI reads the props and draws the layer itself.
  return null;
}

export interface ExplodedUIProps extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
  /** The layers, as `ExplodedUI.Layer`s. The first child is the bottom of the stack. */
  children: React.ReactNode;
  /** Step through the labeled layers one at a time once the stack is open. */
  walkthrough?: boolean;
  /** The viewing angle once tilted, in degrees. `back` tilts the plane away; `turn` turns it on the page first. */
  tilt?: { back?: number; turn?: number };
  /** Gap between layers once open, as a share of the plane's width. */
  spread?: number;
  /** How long the section stays stuck, in stage heights. Defaults to 3, or 4 with a walkthrough. */
  scrollLength?: number;
  /** Land back as the flat screenshot, or stay open as the section scrolls away. */
  end?: "assembled" | "exploded";
  /** Labels beside the stack, numbered markers with a list below it, or none. Side labels become numbered on narrow stages. */
  labels?: "side" | "numbered" | false;
  /** Hover or focus to highlight a layer, and let the pointer turn the open stack. */
  interactive?: boolean;
  /** Called with a layer's index as the walkthrough reaches it, and with null as it leaves the layers. */
  onLayerChange?: (index: number | null) => void;
  /**
   * Scroll container to track. Defaults to the window. The container must be
   * positioned (e.g. `relative`), or scroll progress is measured from the page.
   */
  containerRef?: React.RefObject<HTMLElement | null>;
  /** Classes for the stuck stage. Set its height and background here. */
  stageClassName?: string;
  /** Classes for the plane: the screenshot's box. Size it here. */
  className?: string;
}

type Box = { x: number; y: number; w: number; h: number };
type Point = { x: number; y: number };
type Pose = {
  back: number;
  turn: number;
  /** The pointer's turn, in degrees. */
  px: number;
  py: number;
  scale: number;
  /** Screen offset, in px. */
  tx: number;
  ty: number;
  /** How far the stack sinks so it stays centered on its middle, in px. */
  sink: number;
};

/** Everything measured: redone on resize, read every frame. */
type Geometry = {
  stage: { w: number; h: number };
  plane: Box;
  boxes: Box[];
  gap: number;
  depth: number;
  back: number;
  turn: number;
  /** The tilted plane's scale and offset, so the open stack and its labels fit the stage. */
  fit: { scale: number; tx: number; ty: number };
  /** Where each label's leader or marker attaches, on its layer. */
  anchors: Point[];
  /** Labeled layers, top to bottom on screen. */
  order: number[];
  /** Where the numbered list starts, under the open stack. */
  listTop: number;
  labels: { title: number; desc: number }[];
  /** The numbered markers' size, in px. */
  marker: number;
  shadows: { on: number; from: number; contact: HTMLElement; soft: HTMLElement }[];
};

/** What the pointer needs from the last frame. */
type Live = { pose: Pose; heights: number[]; open: boolean };

function ExplodedUIRoot({
  children,
  walkthrough = false,
  tilt,
  spread = EXPLODE.spread,
  scrollLength = walkthrough ? 4 : 3,
  end = "assembled",
  labels = "side",
  interactive = true,
  onLayerChange,
  containerRef,
  stageClassName,
  className,
  ...props
}: ExplodedUIProps) {
  const still = usePrefersReducedMotion();
  const layers = React.Children.toArray(children).filter(
    (child): child is React.ReactElement<ExplodedUILayerProps> => React.isValidElement(child),
  );
  const count = layers.length;
  const labeled = layers.flatMap((layer, i) => (layer.props.label ? [i] : []));
  const labeledKey = labeled.join(",");
  const bareKey = layers.map((layer) => (layer.props.bare ? 1 : 0)).join("");
  const back = tilt?.back ?? EXPLODE.back;
  const turn = tilt?.turn ?? EXPLODE.turn;

  const sectionRef = React.useRef<HTMLElement>(null);
  const stageRef = React.useRef<HTMLDivElement>(null);
  const planeRef = React.useRef<HTMLDivElement>(null);
  const rigRef = React.useRef<HTMLDivElement>(null);
  const floorRef = React.useRef<HTMLDivElement>(null);
  const floorShadowRef = React.useRef<HTMLDivElement>(null);
  const listRef = React.useRef<HTMLDivElement>(null);
  const layerEls = React.useRef<(HTMLDivElement | null)[]>([]);
  const edgeEls = React.useRef<(HTMLSpanElement | null)[]>([]);
  const outlineEls = React.useRef<(HTMLSpanElement | null)[]>([]);
  const shadowEls = React.useRef(new Map<string, HTMLSpanElement>());
  const labelEls = React.useRef<(HTMLElement | null)[]>([]);
  const titleEls = React.useRef<(HTMLElement | null)[]>([]);
  const descEls = React.useRef<(HTMLElement | null)[]>([]);
  const lineEls = React.useRef<(SVGPolylineElement | null)[]>([]);
  const dotEls = React.useRef<(SVGCircleElement | null)[]>([]);
  const markerEls = React.useRef<(HTMLElement | null)[]>([]);
  const geoRef = React.useRef<Geometry | null>(null);
  const liveRef = React.useRef<Live | null>(null);
  const onLayerChangeRef = React.useRef(onLayerChange);
  const tourRef = React.useRef<number | null>(null);

  const [narrow, setNarrow] = React.useState(false);
  const [stageHeight, setStageHeight] = React.useState(0);
  const [tour, setTour] = React.useState<number | null>(null);
  const [hovered, setHovered] = React.useState<number | null>(null);
  const [pointed, setPointed] = React.useState<number | null>(null);
  const [pinned, setPinned] = React.useState<number | null>(null);
  const active = interactive ? (pointed ?? hovered ?? pinned ?? tour) : tour;
  const mode = labels === "side" && narrow ? "numbered" : labels;

  const { scrollYProgress } = useScroll({
    target: sectionRef,
    container: containerRef,
    offset: ["start start", "end end"],
  });
  const progress = useSpring(scrollYProgress, SCROLL_SPRING);
  const turnX = useSpring(0, POINTER_SPRING);
  const turnY = useSpring(0, POINTER_SPRING);
  // Per layer: how far it has risen as the active one, how much it has faded,
  // and how far it has turned into its outline above the active one.
  const focus = React.useMemo(
    () =>
      Array.from({ length: count }, () => ({
        lift: motionValue(0),
        dim: motionValue(0),
        ghost: motionValue(0),
      })),
    [count],
  );

  React.useEffect(() => {
    onLayerChangeRef.current = onLayerChange;
  });

  React.useEffect(() => {
    focus.forEach(({ lift, dim, ghost }, i) => {
      const above = active !== null && i > active;
      animate(lift, !still && active === i ? 1 : 0, LIFT_SPRING);
      animate(dim, active === null || active === i ? 0 : above ? EXPLODE.fade.above : EXPLODE.fade.below, DIM_SPRING);
      animate(ghost, above ? 1 : 0, DIM_SPRING);
    });
  }, [active, focus, still]);

  React.useLayoutEffect(() => {
    const stage = stageRef.current;
    const plane = planeRef.current;
    const rig = rigRef.current;
    if (!stage || !plane || !rig || !count) return;

    const marked = labeledKey ? labeledKey.split(",").map(Number) : [];
    const tourRange = end === "assembled" ? BEATS.tour : OPEN_TOUR;
    const gaps = new Array<number>(count).fill(0);
    const heights = new Array<number>(count).fill(0);
    let wasOpen = false;

    const measure = () => {
      const stageBox = stage.getBoundingClientRect();
      const planeBox = plane.getBoundingClientRect();
      const w = plane.offsetWidth;
      const h = plane.offsetHeight;
      if (!w || !h) return;

      const isNarrow = stageBox.width < EXPLODE.narrow;
      setNarrow(isNarrow);
      setStageHeight(stage.offsetHeight);
      // The other kind of labels renders first, then this runs again.
      if ((labels === "side" && isNarrow ? "numbered" : labels) !== mode) return;

      const gentle = isNarrow ? EXPLODE.gentle : 1;
      const boxes = Array.from({ length: count }, (_, i) => {
        const el = layerEls.current[i];
        return el
          ? { x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight }
          : { x: 0, y: 0, w, h };
      });
      const sizes = Array.from({ length: count }, (_, i) => ({
        title: titleEls.current[i]?.offsetHeight ?? 0,
        desc: descEls.current[i]?.offsetHeight ?? 0,
        w: Math.max(titleEls.current[i]?.offsetWidth ?? 0, descEls.current[i]?.offsetWidth ?? 0),
      }));
      const widest = Math.max(0, ...marked.map((i) => sizes[i].w));

      const geo: Geometry = {
        stage: { w: stage.offsetWidth, h: stage.offsetHeight },
        plane: { x: planeBox.left - stageBox.left, y: planeBox.top - stageBox.top, w, h },
        boxes,
        gap: spread * w,
        depth: EXPLODE.perspective * Math.max(w, h),
        back: back * gentle,
        turn: turn * gentle,
        fit: { scale: 1, tx: 0, ty: 0 },
        anchors: [],
        order: [],
        listTop: 0,
        labels: sizes,
        marker: markerEls.current[marked[0]]?.offsetWidth ?? 0,
        shadows: [],
      };
      fitStack(geo, {
        kind: mode === "numbered" ? "numbered" : "side",
        side: mode === "side" ? LABEL.leader + widest : 0,
        below: mode === "numbered" ? (listRef.current?.offsetHeight ?? 0) : 0,
        marked,
      });
      if (listRef.current) listRef.current.style.top = `${geo.listTop}px`;

      // Each layer's shadow falls on every sheet under it, clipped to that sheet.
      for (const [key, el] of shadowEls.current) {
        const [on, from, kind] = key.split(":");
        const a = boxes[Number(on)];
        const b = boxes[Number(from)];
        const overlaps = b.x < a.x + a.w && a.x < b.x + b.w && b.y < a.y + a.h && a.y < b.y + b.h;
        el.style.display = overlaps ? "" : "none";
        if (!overlaps) continue;
        el.style.left = `${b.x - a.x}px`;
        el.style.top = `${b.y - a.y}px`;
        el.style.width = `${b.w}px`;
        el.style.height = `${b.h}px`;
        el.style.filter = `blur(${(kind === "contact" ? 0.004 : 0.02) * w}px)`;
        if (kind === "soft") {
          const contact = shadowEls.current.get(`${on}:${from}:contact`);
          if (contact) geo.shadows.push({ on: Number(on), from: Number(from), contact, soft: el });
        }
      }
      if (floorShadowRef.current) floorShadowRef.current.style.filter = `blur(${0.04 * w}px)`;

      // Outlines sit beside their layers, not in them, so they stay when the
      // layer and anything styled on it fade out.
      boxes.forEach((box, i) => {
        const outline = outlineEls.current[i];
        const layer = layerEls.current[i];
        if (!outline || !layer) return;
        outline.style.left = `${box.x}px`;
        outline.style.top = `${box.y}px`;
        outline.style.width = `${box.w}px`;
        outline.style.height = `${box.h}px`;
        outline.style.borderRadius = getComputedStyle(layer).borderRadius;
      });

      geoRef.current = geo;
      schedule();
    };

    const update = () => {
      const g = geoRef.current;
      if (!g) return;
      const p = progress.get();

      // How tilted the plane is, and how open each gap is.
      let tilted = 1;
      if (still) {
        gaps.fill(1);
        gaps[0] = 0;
      } else {
        tilted = EXPLODE.ease(segment(p, BEATS.tilt));
        if (end === "assembled") tilted *= 1 - EXPLODE.ease(segment(p, BEATS.flatten));
        openGaps(p, end, gaps);
      }
      let rise = 0;
      for (let i = 0; i < count; i++) {
        rise += gaps[i] * g.gap;
        heights[i] = rise;
      }
      // Open is the last gap to open and the first to close.
      const open = count > 1 ? gaps[1] : tilted;
      const pose: Pose = {
        back: g.back * tilted,
        turn: g.turn * tilted,
        px: turnX.get() * open,
        py: turnY.get() * open,
        scale: 1 + (g.fit.scale - 1) * tilted,
        tx: g.fit.tx * tilted,
        ty: g.fit.ty * tilted,
        sink: heights[count - 1] / 2,
      };
      const isOpen = still || (tilted > 0.98 && open > 0.98);
      liveRef.current = { pose, heights: heights.slice(), open: isOpen };

      // The walkthrough's layer.
      let nextTour: number | null = null;
      if (walkthrough && !still && marked.length) {
        const u = (p - tourRange[0]) / (tourRange[1] - tourRange[0]);
        if (u >= 0 && u < 1) nextTour = marked[Math.floor(u * marked.length)];
      }
      if (nextTour !== tourRef.current) {
        tourRef.current = nextTour;
        setTour(nextTour);
        // Scrolling takes over from the pointer.
        setHovered(null);
        setPinned(null);
        onLayerChangeRef.current?.(nextTour);
      }
      // Closing the stack lets go of anything the pointer picked.
      if (wasOpen && !isOpen) {
        setHovered(null);
        setPinned(null);
        setPointed(null);
      }
      wasOpen = isOpen;

      // Flat and still, nothing is 3D, so the text renders pin-sharp.
      const z = heights.map((rest, i) => rest + focus[i].lift.get() * EXPLODE.lift * g.gap);
      const flat = tilted === 0 && z.every((v) => v === 0);
      rig.style.transform = flat ? "none" : rigTransform(pose);
      plane.style.perspective = flat ? "" : `${g.depth}px`;

      for (let i = 0; i < count; i++) {
        const el = layerEls.current[i];
        if (!el) continue;
        el.style.transform = flat ? "none" : `translateZ(${z[i]}px)`;
        const fade = 1 - focus[i].dim.get();
        el.style.opacity = fade < 1 ? String(fade) : "";
        const outline = outlineEls.current[i];
        if (outline) {
          outline.style.transform = flat ? "none" : `translateZ(${z[i]}px)`;
          outline.style.opacity = String(focus[i].ghost.get());
        }
        const edge = edgeEls.current[i];
        if (edge) edge.style.opacity = String(i === 0 ? tilted : Math.min(1, gaps[i] * 2));
      }

      // A shadow starts crisp and dark, then spreads and fades as its gap
      // widens. A layer fading back casts a fainter one.
      for (const { on, from, contact, soft } of g.shadows) {
        const lift = (z[from] - z[on]) / g.gap;
        const cast = 1 - focus[from].dim.get();
        contact.style.opacity = String(cast * 0.22 * smooth(0, 0.15, lift) * (1 - smooth(0.2, 1, lift)));
        soft.style.opacity = String((cast * 0.13 * smooth(0.05, 0.8, lift)) / (1 + 0.5 * Math.max(0, lift - 1)));
        soft.style.transform = `translate(${0.04 * lift * g.gap}px, ${0.16 * lift * g.gap}px) scale(${1 + 0.02 * lift})`;
      }

      const floor = floorRef.current;
      if (floor) {
        floor.style.opacity = String(tilted);
        floor.style.visibility = tilted > 0 ? "visible" : "hidden";
        floor.style.transform = `translateZ(${-EXPLODE.floor * g.gap}px)`;
      }
      if (floorShadowRef.current) {
        floorShadowRef.current.style.opacity = String(open * (1 - focus[0].dim.get()));
      }

      if (mode) drawLabels(g, pose, z, gaps, tilted);
    };

    const drawLabels = (g: Geometry, pose: Pose, z: number[], gaps: number[], tilted: number) => {
      const project = projector(pose, g);
      const at = (i: number) => {
        const a = project(g.anchors[i].x, g.anchors[i].y, z[i]);
        return { x: a.x + g.plane.x, y: a.y + g.plane.y };
      };
      // A label shows up as its layer lifts off; the base's, as the last gap opens.
      const reveal = (i: number) =>
        still ? 1 : count > 1 ? smooth(i === 0 ? 0.6 : 0.3, 1, gaps[Math.max(1, i)]) : tilted;

      if (mode === "numbered") {
        for (const i of g.order) {
          const marker = markerEls.current[i];
          if (!marker) continue;
          const a = at(i);
          const r = reveal(i);
          const size = g.marker;
          marker.style.visibility = r > 0 ? "visible" : "hidden";
          marker.style.opacity = String(r * (1 - LABEL.dim * dimmed(focus[i].dim.get())));
          marker.style.transform = `translate3d(${Math.round(a.x - size / 2)}px, ${Math.round(a.y - size / 2)}px, 0) scale(${0.6 + 0.4 * r + 0.15 * clamp01(focus[i].lift.get())})`;
          const chip = labelEls.current[i];
          if (chip) {
            chip.style.visibility = r > 0 ? "visible" : "hidden";
            chip.style.opacity = String(r);
          }
        }
        return;
      }

      const anchors = g.order.map(at);
      const column = Math.max(...anchors.map((a) => a.x)) + LABEL.leader;
      const items = g.order.map((i, k) => {
        const open = still ? 1 : clamp01(focus[i].lift.get());
        return {
          i,
          anchor: anchors[k],
          open,
          top: anchors[k].y - g.labels[i].title / 2,
          height: g.labels[i].title + (g.labels[i].desc + 4) * open,
          weight: 1 + 4 * open,
        };
      });
      stack(items, LABEL.pad, g.stage.h - LABEL.pad);

      for (const { i, anchor, open, top } of items) {
        const label = labelEls.current[i];
        const line = lineEls.current[i];
        const dot = dotEls.current[i];
        const r = reveal(i);
        const drawn = clamp01(r / 0.6);
        const shown = smooth(0.45, 1, r);
        const fade = 1 - LABEL.dim * dimmed(focus[i].dim.get());
        const y = top + g.labels[i].title / 2;
        if (label) {
          label.style.visibility = shown > 0 ? "visible" : "hidden";
          label.style.opacity = String(shown * fade);
          label.style.transform = `translate3d(${Math.round(column + 8 * (shown - 1))}px, ${Math.round(top)}px, 0)`;
          const desc = descEls.current[i];
          if (desc) {
            desc.style.opacity = String(open);
            desc.style.transform = `translateY(${(open - 1) * 4}px)`;
          }
        }
        if (line) {
          line.style.opacity = String(fade);
          line.setAttribute(
            "points",
            leader(
              [anchor, { x: column - LABEL.knee, y: anchor.y }, { x: column - 6, y }],
              drawn,
            ),
          );
        }
        if (dot) {
          dot.style.opacity = String(fade * Math.min(1, drawn * 3));
          dot.style.transform = `translate(${anchor.x}px, ${anchor.y}px) scale(${Math.min(1, drawn * 3) * (1 + 0.5 * open)})`;
        }
      }
    };

    const schedule = () => frame.render(update);
    measure();

    const unsubscribe = [progress, turnX, turnY, ...focus.flatMap((f) => [f.lift, f.dim, f.ghost])].map(
      (value: MotionValue<number>) => value.on("change", schedule),
    );
    const observer = new ResizeObserver(measure);
    const watched = [
      stage,
      plane,
      listRef.current,
      ...layerEls.current,
      ...titleEls.current,
      ...descEls.current,
    ];
    for (const el of watched) if (el) observer.observe(el);

    return () => {
      unsubscribe.forEach((stop) => stop());
      observer.disconnect();
      cancelFrame(update);
    };
  }, [
    back,
    bareKey,
    count,
    end,
    focus,
    labeledKey,
    labels,
    mode,
    progress,
    spread,
    still,
    turn,
    turnX,
    turnY,
    walkthrough,
  ]);

  // The pointer turns the open stack, and hovering a layer highlights it.
  const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
    if (!interactive || event.pointerType !== "mouse") return;
    const stage = stageRef.current;
    const g = geoRef.current;
    const live = liveRef.current;
    if (!stage || !g || !live) return;
    const box = stage.getBoundingClientRect();
    const x = event.clientX - box.left;
    const y = event.clientY - box.top;
    if (!still) {
      turnY.set((x / box.width - 0.5) * 2 * EXPLODE.pointer);
      turnX.set((0.5 - y / box.height) * 2 * EXPLODE.pointer);
    }
    if (!live.open) return;
    // Hit-tested where the layers rest, without their hover lift, so a layer
    // rising never slips out from under the pointer and drops back.
    const project = projector(live.pose, g);
    let hit: number | null = null;
    for (let i = count - 1; i >= 0; i--) {
      const b = g.boxes[i];
      const corners = [
        [b.x, b.y],
        [b.x + b.w, b.y],
        [b.x + b.w, b.y + b.h],
        [b.x, b.y + b.h],
      ].map(([cx, cy]) => {
        const p = project(cx, cy, live.heights[i]);
        return { x: p.x + g.plane.x, y: p.y + g.plane.y };
      });
      if (inside({ x, y }, corners)) {
        hit = i;
        break;
      }
    }
    setHovered(hit);
  };

  const onPointerLeave = () => {
    turnX.set(0);
    turnY.set(0);
    setHovered(null);
  };

  /** Scrolls the walkthrough to a layer, or pins it when there's no walkthrough to scroll. */
  const choose = (i: number) => {
    const section = sectionRef.current;
    if (!liveRef.current?.open) return;
    if (!walkthrough || still || !section) {
      setPinned((current) => (current === i ? null : i));
      return;
    }
    const range = end === "assembled" ? BEATS.tour : OPEN_TOUR;
    const slot = labeled.indexOf(i);
    const at = range[0] + ((slot + 0.5) / labeled.length) * (range[1] - range[0]);
    const scroller = containerRef?.current;
    const top = section.getBoundingClientRect().top - (scroller?.getBoundingClientRect().top ?? 0);
    const offset = scroller ? scroller.scrollTop : window.scrollY;
    const view = scroller ? scroller.clientHeight : window.innerHeight;
    const target = offset + top + at * (section.offsetHeight - view);
    (scroller ?? window).scrollTo({ top: target, behavior: "smooth" });
  };

  const labelHandlers = (i: number) =>
    interactive
      ? {
          onPointerEnter: () => {
            if (liveRef.current?.open) setPointed(i);
          },
          onPointerLeave: () => setPointed((current) => (current === i ? null : current)),
          onFocus: () => {
            if (liveRef.current?.open) setPointed(i);
          },
          onBlur: () => setPointed((current) => (current === i ? null : current)),
          onClick: () => choose(i),
        }
      : {};

  return (
    <section ref={sectionRef} className="relative" {...props}>
      <div
        ref={stageRef}
        onPointerMove={onPointerMove}
        onPointerLeave={onPointerLeave}
        className={cn(
          "relative flex h-dvh w-full items-center justify-center overflow-hidden px-4 sm:px-6",
          !still && "sticky top-0",
          stageClassName,
        )}
      >
        <div
          ref={planeRef}
          className={cn("@container relative aspect-[16/10] w-full max-w-5xl", className)}
        >
          {/* Nothing in the stack is interactive: it's a picture of the product. */}
          <div ref={rigRef} inert className="absolute inset-0 [transform-style:preserve-3d]">
            <div ref={floorRef} aria-hidden className="pointer-events-none invisible absolute inset-0 opacity-0">
              <div className="absolute -inset-[30%] [--grid:rgb(9_9_11/0.09)] [background-image:linear-gradient(to_right,var(--grid)_1px,transparent_1px),linear-gradient(to_bottom,var(--grid)_1px,transparent_1px)] [background-position:center] [background-size:5cqw_5cqw] [mask-image:radial-gradient(closest-side,#000_55%,transparent)] dark:[--grid:rgb(255_255_255/0.08)]" />
              <div
                ref={floorShadowRef}
                className="absolute inset-[4%] rounded-[8%] bg-zinc-950/15 opacity-0 dark:bg-black/60"
              />
            </div>

            {layers.map((layer, i) => {
              const { className: placement, bare, children: content } = layer.props;
              return (
                <div
                  key={layer.key ?? i}
                  ref={(el) => {
                    layerEls.current[i] = el;
                  }}
                  className={cn(
                    "absolute",
                    !(placement && PLACED.test(placement)) && "inset-0",
                    !bare && "overflow-hidden rounded-lg",
                    placement,
                  )}
                >
                  {content}
                  {/* Shadows cast on this sheet by the layers above it. */}
                  {!bare
                    ? layers.slice(i + 1).map((above, k) =>
                        above.props.bare
                          ? null
                          : (["contact", "soft"] as const).map((kind) => (
                              <span
                                key={`${i + 1 + k}:${kind}`}
                                ref={(el) => {
                                  const key = `${i}:${i + 1 + k}:${kind}`;
                                  if (el) shadowEls.current.set(key, el);
                                  else shadowEls.current.delete(key);
                                }}
                                aria-hidden
                                className="pointer-events-none absolute rounded-[inherit] bg-zinc-950 opacity-0 will-change-[opacity,transform] dark:bg-black"
                              />
                            )),
                      )
                    : null}
                  {/* The sheet's edge and the light along its top, once it's off the page. */}
                  {!bare ? (
                    <span
                      ref={(el) => {
                        edgeEls.current[i] = el;
                      }}
                      aria-hidden
                      className="pointer-events-none absolute inset-0 rounded-[inherit] opacity-0 ring-1 ring-zinc-950/10 will-change-[opacity] ring-inset dark:ring-white/15"
                    >
                      <span className="absolute inset-x-[6%] top-0 h-px bg-linear-to-r from-transparent via-white to-transparent dark:via-white/40" />
                    </span>
                  ) : null}
                </div>
              );
            })}

            {/* What's left of a layer above the active one: its edge. */}
            {layers.map((layer, i) =>
              layer.props.bare ? null : (
                <span
                  key={`outline-${layer.key ?? i}`}
                  ref={(el) => {
                    outlineEls.current[i] = el;
                  }}
                  aria-hidden
                  className="pointer-events-none absolute top-0 left-0 border border-zinc-950/25 opacity-0 will-change-[opacity] dark:border-white/25"
                />
              ),
            )}
          </div>
        </div>

        {mode === "side" ? (
          <>
            <svg aria-hidden className="pointer-events-none absolute inset-0 size-full overflow-visible text-zinc-400 dark:text-zinc-500">
              {labeled.map((i) => (
                <g key={i}>
                  <polyline
                    ref={(el) => {
                      lineEls.current[i] = el;
                    }}
                    fill="none"
                    stroke="currentColor"
                    strokeWidth={1.5}
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    strokeDasharray="0 4.5"
                  />
                  <circle
                    ref={(el) => {
                      dotEls.current[i] = el;
                    }}
                    r={3}
                    className="fill-zinc-950 opacity-0 [transform-box:fill-box] [transform-origin:center] dark:fill-zinc-50"
                  />
                </g>
              ))}
            </svg>
            <ol className="pointer-events-none absolute inset-0">
              {labeled.map((i) => {
                const { label, description } = layers[i].props;
                const Tag = interactive ? "button" : "div";
                return (
                  <li
                    key={i}
                    ref={(el) => {
                      labelEls.current[i] = el;
                    }}
                    className="invisible absolute top-0 left-0 opacity-0 will-change-[transform,opacity]"
                  >
                    <Tag
                      {...(interactive ? { type: "button" as const } : {})}
                      {...labelHandlers(i)}
                      className={cn(
                        "relative -mx-2 -my-1 block rounded-md px-2 py-1 text-left outline-none",
                        interactive && "pointer-events-auto cursor-pointer focus-visible:ring-2 focus-visible:ring-zinc-950/15 dark:focus-visible:ring-white/20",
                      )}
                    >
                      <span
                        ref={(el) => {
                          titleEls.current[i] = el;
                        }}
                        className="block text-sm font-medium whitespace-nowrap text-zinc-950 dark:text-zinc-50"
                      >
                        {label}
                      </span>
                      {description ? (
                        <span
                          ref={(el) => {
                            descEls.current[i] = el;
                          }}
                          className="absolute top-full left-2 block w-max max-w-[15rem] text-[13px] leading-snug text-pretty text-zinc-500 opacity-0 dark:text-zinc-400"
                        >
                          {description}
                        </span>
                      ) : null}
                    </Tag>
                  </li>
                );
              })}
            </ol>
          </>
        ) : null}

        {mode === "numbered" ? (
          <>
            {labeled.map((i, n) => (
              <span
                key={i}
                ref={(el) => {
                  markerEls.current[i] = el;
                }}
                aria-hidden
                className="pointer-events-none invisible absolute top-0 left-0 grid size-5 place-items-center rounded-full bg-zinc-950 text-[11px] font-semibold text-white tabular-nums opacity-0 shadow-sm will-change-[transform,opacity] dark:bg-white dark:text-zinc-950"
              >
                {n + 1}
              </span>
            ))}
            <div
              ref={listRef}
              className="pointer-events-none absolute inset-x-0 top-0 flex flex-col items-center gap-2 px-4"
            >
              <ol className="flex flex-wrap justify-center gap-1">
                {labeled.map((i, n) => {
                  const { label } = layers[i].props;
                  const Tag = interactive ? "button" : "div";
                  return (
                    <li
                      key={i}
                      ref={(el) => {
                        labelEls.current[i] = el;
                      }}
                      className="invisible opacity-0"
                    >
                      <Tag
                        {...(interactive ? { type: "button" as const } : {})}
                        {...labelHandlers(i)}
                        className={cn(
                          "flex items-center gap-1.5 rounded-full py-1 pr-2.5 pl-1 text-[13px] font-medium whitespace-nowrap text-zinc-500 transition-colors duration-200 dark:text-zinc-400",
                          active === i && "bg-zinc-950/5 text-zinc-950 dark:bg-white/10 dark:text-zinc-50",
                          interactive && "pointer-events-auto cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-zinc-950/15 dark:focus-visible:ring-white/20",
                        )}
                      >
                        <span className="grid size-4.5 place-items-center rounded-full bg-zinc-950/10 text-[10px] font-semibold tabular-nums dark:bg-white/15">
                          {n + 1}
                        </span>
                        {label}
                      </Tag>
                    </li>
                  );
                })}
              </ol>
              <div aria-live="polite" className="grid min-h-[2lh] max-w-sm text-center text-[13px] leading-snug text-pretty text-zinc-500 dark:text-zinc-400">
                {labeled.map((i) => (
                  <p
                    key={i}
                    className={cn(
                      "col-start-1 row-start-1 transition-opacity duration-200",
                      active === i ? "opacity-100" : "opacity-0",
                    )}
                  >
                    {layers[i].props.description}
                  </p>
                ))}
              </div>
            </div>
          </>
        ) : null}
      </div>
      {/* Scroll runway: the stage stays stuck while this scrolls past. */}
      {!still ? (
        <div
          aria-hidden
          style={{ height: stageHeight ? stageHeight * scrollLength : `${scrollLength * 100}dvh` }}
        />
      ) : null}
    </section>
  );
}

/**
 * A product screenshot that comes apart into its layers as you scroll,
 * explains each one, then clicks back together.
 */
export const ExplodedUI = Object.assign(ExplodedUIRoot, { Layer: ExplodedUILayer });

/** The rig's transform. `projector` below is the same thing, as math. */
function rigTransform(pose: Pose) {
  return (
    `translate3d(${pose.tx}px, ${pose.ty}px, 0) rotateX(${pose.px}deg) rotateY(${pose.py}deg) ` +
    `scale3d(${pose.scale}, ${pose.scale}, ${pose.scale}) rotateX(${pose.back}deg) ` +
    `rotateZ(${pose.turn}deg) translateZ(${-pose.sink}px)`
  );
}

/**
 * Where a point on the plane lands on screen under a pose: the rig's
 * transform, then the plane's perspective. Takes plane coordinates and a
 * layer's height; returns plane coordinates, flat.
 */
function projector(pose: Pose, g: Pick<Geometry, "plane" | "depth">) {
  const rad = Math.PI / 180;
  const sb = Math.sin(pose.back * rad);
  const cb = Math.cos(pose.back * rad);
  const st = Math.sin(pose.turn * rad);
  const ct = Math.cos(pose.turn * rad);
  const sx = Math.sin(pose.px * rad);
  const cx = Math.cos(pose.px * rad);
  const sy = Math.sin(pose.py * rad);
  const cy = Math.cos(pose.py * rad);
  const ox = g.plane.w / 2;
  const oy = g.plane.h / 2;
  return (x: number, y: number, z: number): Point => {
    let X = x - ox;
    let Y = y - oy;
    let Z = z - pose.sink;
    let t = X * ct - Y * st; // rotateZ(turn)
    Y = X * st + Y * ct;
    X = t;
    t = Y * cb - Z * sb; // rotateX(back)
    Z = Y * sb + Z * cb;
    Y = t;
    X *= pose.scale;
    Y *= pose.scale;
    Z *= pose.scale;
    t = X * cy + Z * sy; // rotateY(py)
    Z = -X * sy + Z * cy;
    X = t;
    t = Y * cx - Z * sx; // rotateX(px)
    Z = Y * sx + Z * cx;
    Y = t;
    X += pose.tx;
    Y += pose.ty;
    const k = g.depth / (g.depth - Z);
    return { x: ox + X * k, y: oy + Y * k };
  };
}

/**
 * Scales and centers the open stack so it fits the stage with its labels,
 * and picks where each label attaches. Side labels attach at the corner of
 * the layer nearest them; numbered markers sit on the layer's front edge,
 * the part of each sheet that stays in view.
 */
function fitStack(g: Geometry, room: { kind: "side" | "numbered"; side: number; below: number; marked: number[] }) {
  const n = g.boxes.length;
  const pose: Pose = {
    back: g.back,
    turn: g.turn,
    px: 0,
    py: 0,
    scale: 1,
    tx: 0,
    ty: 0,
    sink: ((n - 1) * g.gap) / 2,
  };
  const lift = EXPLODE.lift * g.gap;
  const below = room.below ? room.below + LABEL.pad : 0;
  const availW = g.stage.w - LABEL.pad * 2;
  const availH = g.stage.h - LABEL.pad * 2 - below;

  const rest = projector(pose, g);
  g.anchors = g.boxes.map((b, i) => {
    const inset = Math.min(10, b.w * 0.08, b.h * 0.08);
    const points =
      room.kind === "side"
        ? [
            [b.x + inset, b.y + inset],
            [b.x + b.w - inset, b.y + inset],
            [b.x + b.w - inset, b.y + b.h - inset],
            [b.x + inset, b.y + b.h - inset],
          ]
        : [
            [b.x + b.w / 2, b.y + inset * 2],
            [b.x + b.w - inset * 2, b.y + b.h / 2],
            [b.x + b.w / 2, b.y + b.h - inset * 2],
            [b.x + inset * 2, b.y + b.h / 2],
          ];
    // Side: the furthest right. Numbered: the lowest on screen, so the front.
    const score = ([x, y]: number[]) => {
      const p = rest(x, y, i * g.gap);
      return room.kind === "side" ? p.x : p.y;
    };
    const [x, y] = points.reduce((best, point) => (score(point) > score(best) ? point : best));
    return { x, y };
  });
  g.order = [...room.marked].sort(
    (a, b) =>
      rest(g.anchors[a].x, g.anchors[a].y, a * g.gap).y - rest(g.anchors[b].x, g.anchors[b].y, b * g.gap).y,
  );

  for (let pass = 0; pass < 3; pass++) {
    const project = projector(pose, g);
    let minX = Infinity;
    let maxX = -Infinity;
    let minY = Infinity;
    let maxY = -Infinity;
    g.boxes.forEach((b, i) => {
      // Where it rests, and raised as the active layer.
      for (const z of [i * g.gap, i * g.gap + lift]) {
        for (const [x, y] of [
          [b.x, b.y],
          [b.x + b.w, b.y],
          [b.x + b.w, b.y + b.h],
          [b.x, b.y + b.h],
        ]) {
          const p = project(x, y, z);
          minX = Math.min(minX, p.x);
          maxX = Math.max(maxX, p.x);
          minY = Math.min(minY, p.y);
          maxY = Math.max(maxY, p.y);
        }
      }
    });
    let reach = -Infinity;
    for (const i of room.marked) {
      reach = Math.max(reach, project(g.anchors[i].x, g.anchors[i].y, i * g.gap).x);
    }
    const right = room.side ? Math.max(maxX, reach + room.side) : maxX;

    if (pass === 0) {
      pose.scale = Math.max(
        0.3,
        Math.min(
          EXPLODE.scale,
          availW / (maxX - minX),
          room.side ? (availW - room.side) / (reach - minX) : Infinity,
          availH / (maxY - minY),
        ),
      );
      continue;
    }
    // Center the stack, with its labels beside it or its list under it.
    const shift = g.stage.h / 2 - g.plane.y - (minY + maxY + below) / 2;
    pose.tx += g.stage.w / 2 - g.plane.x - (minX + right) / 2;
    pose.ty += shift;
    g.listTop = g.plane.y + maxY + shift + LABEL.pad;
  }
  g.fit = { scale: pose.scale, tx: pose.tx, ty: pose.ty };
}

/** How open each gap is, 0 to 1. gaps[i] is the one under layer i, so gaps[0] stays 0. */
function openGaps(p: number, end: "assembled" | "exploded", gaps: number[]) {
  const m = gaps.length - 1;
  if (m < 1) return;
  // Each gap gets `span` of the beat, starting `step` after the one above it.
  const span = 1 / (1 + (m - 1) * (1 - EXPLODE.overlap));
  const step = span * (1 - EXPLODE.overlap);
  const opening = segment(p, BEATS.explode);
  const closing = end === "assembled" ? segment(p, BEATS.collapse) : 0;
  for (let k = 1; k <= m; k++) {
    const opened = EXPLODE.ease(clamp01((opening - (m - k) * step) / span)); // top first
    const closed = EXPLODE.ease(clamp01((closing - (k - 1) * step) / span)); // bottom first
    gaps[k] = opened * (1 - closed);
  }
}

/**
 * Spaces labels so none overlap, moving each as little as it can. An open
 * label weighs more, so its neighbors make room around it.
 */
function stack(items: { top: number; height: number; weight: number }[], min: number, max: number) {
  for (let pass = 0; pass < 6; pass++) {
    for (let k = 1; k < items.length; k++) {
      const a = items[k - 1];
      const b = items[k];
      const overlap = a.top + a.height + LABEL.gap - b.top;
      if (overlap <= 0) continue;
      const share = b.weight / (a.weight + b.weight);
      a.top -= overlap * share;
      b.top += overlap * (1 - share);
    }
  }
  // Then keep them apart, and on the stage.
  for (let k = 0; k < items.length; k++) {
    const floor = k ? items[k - 1].top + items[k - 1].height + LABEL.gap : min;
    items[k].top = Math.max(items[k].top, floor);
  }
  for (let k = items.length - 1; k >= 0; k--) {
    const ceiling = k < items.length - 1 ? items[k + 1].top - LABEL.gap : max;
    items[k].top = Math.max(min, Math.min(items[k].top, ceiling - items[k].height));
  }
}

/** The first `drawn` share of a leader, as SVG points. */
function leader(points: Point[], drawn: number) {
  const lengths = points.slice(1).map((p, i) => Math.hypot(p.x - points[i].x, p.y - points[i].y));
  let left = drawn * lengths.reduce((sum, l) => sum + l, 0);
  const out = [points[0]];
  for (let i = 1; i < points.length && left > 0; i++) {
    const l = lengths[i - 1];
    const f = l > 0 ? Math.min(1, left / l) : 1;
    out.push({
      x: points[i - 1].x + (points[i].x - points[i - 1].x) * f,
      y: points[i - 1].y + (points[i].y - points[i - 1].y) * f,
    });
    left -= l;
  }
  return out.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ");
}

/** Whether a point is inside a convex quad. */
function inside(p: Point, quad: Point[]) {
  let sign = 0;
  for (let i = 0; i < quad.length; i++) {
    const a = quad[i];
    const b = quad[(i + 1) % quad.length];
    const cross = (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
    if (cross === 0) continue;
    if (sign === 0) sign = Math.sign(cross);
    else if (Math.sign(cross) !== sign) return false;
  }
  return true;
}

/** How far a layer has faded back, 0 to 1, whichever side of the active one it's on. */
function dimmed(fade: number) {
  return Math.min(1, fade / EXPLODE.fade.below);
}

function segment(p: number, [a, b]: readonly [number, number]) {
  return clamp01((p - a) / (b - a));
}

function clamp01(v: number) {
  return v < 0 ? 0 : v > 1 ? 1 : v;
}

function smooth(a: number, b: number, v: number) {
  const x = clamp01((v - a) / (b - a));
  return x * x * (3 - 2 * x);
}

const REDUCED_MOTION = "(prefers-reduced-motion: reduce)";

function subscribeToReducedMotion(onChange: () => void) {
  const query = window.matchMedia(REDUCED_MOTION);
  query.addEventListener("change", onChange);
  return () => query.removeEventListener("change", onChange);
}

/** Reads false on the server and while hydrating, so the markup always matches. */
function usePrefersReducedMotion() {
  return React.useSyncExternalStore(
    subscribeToReducedMotion,
    () => window.matchMedia(REDUCED_MOTION).matches,
    () => false,
  );
}
```
