{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "black-box",
  "type": "registry:ui",
  "title": "Black Box",
  "description": "A glossy black box that shows its work. Each face is one part of your product, and they open in turn: the panel lifts off its socket, light leaks out round it, and a hairline draws out to a label saying what that part did. Point at a face, tap it or tab to it to open it yourself. In dark mode the box turns to glass, a wireframe with a small lit core that turns inside.",
  "author": "KeyframeUI <https://keyframeui.com>",
  "categories": [
    "3d",
    "hero",
    "ai",
    "diagram"
  ],
  "docs": "Live preview, examples and props: https://keyframeui.com/components/black-box",
  "dependencies": [
    "motion",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "components/ui/black-box.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { animate, useInView, useReducedMotion } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Keep:\n * - Panels, lights, lines and labels animate only transform and opacity, through `animate()` on\n *   the nodes kept in `parts`, each on its own layer, so nothing repaints while a panel flies.\n * - Requests for a face resolve in one order: hover, then keyboard focus, then a pin, then\n *   autoplay. A controlled `value` overrides them all.\n * - A mouse opens a face only after resting on it for the hover intent, and the face stays open\n *   while the pointer is anywhere over the box; leaving waits before it closes.\n * - Autoplay holds while the pointer is over the box, focus is inside it, the tab is hidden or\n *   the box is under 40% in view, then picks up from the face left open. A click or Esc stops it\n *   for good.\n * - Reduced motion has no fly-in, lift, turn or autoplay: every change fades, and the first face\n *   rests open once hydrated, since `useReducedMotion` is already true in the hydration render.\n */\nconst TIMING = {\n  /** The panels flying in to shut the box. */\n  assemble: { type: \"spring\", visualDuration: 0.7, bounce: 0.12 },\n  /** Between panels flying in, in seconds. */\n  stagger: 0.07,\n  /** The frame appearing under the panels, and each panel fading in as it flies. */\n  reveal: { duration: 0.35, ease: [0.23, 1, 0.32, 1] },\n  /** Light through the seams as the box shuts, or as its material changes. */\n  flash: { duration: 0.9, ease: \"easeOut\" },\n  /** After the panels start flying in, before the seams flash, in seconds. */\n  flashAfter: 0.42,\n  /** Between the faces' flashes, in seconds. */\n  flashStagger: 0.04,\n  /** After the panels start flying in, before the box counts as shut and a face can open, in ms. */\n  shut: 950,\n  /** A panel lifting off its socket (the `liftDuration` and `liftBounce` props). */\n  open: { type: \"spring\", visualDuration: 0.42, bounce: 0.16 },\n  /** A panel settling back in. */\n  close: { type: \"spring\", visualDuration: 0.3, bounce: 0 },\n  /** The glass core turning a quarter to follow. */\n  turn: { type: \"spring\", visualDuration: 0.6, bounce: 0.06 },\n  /** The highlight crossing an opening panel. */\n  sheen: { duration: 0.8, ease: [0.23, 1, 0.32, 1] },\n  /** The panel leading its dot, line and caption, in seconds. */\n  lead: 0.08,\n  /** The label trailing its dot, in seconds. */\n  labelLag: 0.06,\n  /** The leader line drawing out from the dot. */\n  draw: { duration: 0.28, ease: [0.23, 1, 0.32, 1] },\n  /** Label, dot and light coming in. */\n  show: { duration: 0.22, ease: [0.23, 1, 0.32, 1] },\n  /** Light spreading onto the page. */\n  spill: { duration: 0.4, ease: [0.23, 1, 0.32, 1] },\n  /** Label, line and light going, faster than they came. */\n  hide: { duration: 0.15, ease: [0.23, 1, 0.32, 1] },\n  /** Reduced motion: every change is a fade. */\n  fade: { duration: 0.2, ease: [0.23, 1, 0.32, 1] },\n  /** A mouse resting on a face before it opens, in ms. */\n  intent: 80,\n  /** An open face waiting after the pointer leaves the box, in ms. */\n  linger: 250,\n  /** After the pointer leaves, before autoplay picks up, in ms. */\n  resume: 700,\n  /** Each face staying open under `autoplay={true}`, in ms. */\n  autoplay: 3200,\n  /** The box resting shut between rounds, as a share of the time per face. */\n  rest: 0.5,\n  /** Before the first face opens once the box has shut, in ms. */\n  first: 300,\n} as const;\n\n/* ─────────────────────────────────────────────────────────\n * BLACK BOX STORYBOARD\n *\n *  rest      a glossy black cube seen from above one corner, so it\n *            reads as a hexagon: three panels (top, left, right) set\n *            into a darker frame, a soft shadow under it. As glass\n *            (the dark-mode default) it's a wireframe in light lines,\n *            the hidden edges showing through and a small lit core\n *            hanging inside\n *  view      the first time it's 40% in view the panels come in from\n *            55% of an edge out along their faces, 70ms apart, on a\n *            0.7s spring with a little give, and shut. As they land,\n *            light flashes through the seams once (0.9s)\n *  open      a face opens: its panel lifts 15% of an edge off its\n *            socket on a 0.42s spring with a little give, the socket\n *            lights up in the accent, the light spills onto the page\n *            around the box and a sheen crosses the panel. 80ms later\n *            a dot lands on the panel, a hairline draws out to the\n *            label (0.28s) and the label rises 6px into place\n *  close     the panel settles back in 0.3s with no bounce while the\n *            label, line and light go in 0.15s, faster than they came\n *  autoplay  each face opens for 3.2s in turn, the core inside turning\n *            a quarter to follow, then the box rests shut for half\n *            that and starts again. A pointer over the box, focus\n *            inside it, a hidden tab or the box out of view holds it;\n *            leaving picks up 0.7s later from the face left open. A\n *            click stops it for good\n *  point     with a mouse, resting 80ms on a face opens it. It stays\n *            open while the pointer is anywhere over the box and\n *            closes 250ms after it leaves\n *  touch     a tap opens a face and keeps it open; a second tap, Esc\n *            or a tap outside closes it\n *  keys      the faces are buttons behind one tab stop. Arrows move\n *            between them and open each, Enter or Space pins one,\n *            Esc closes\n *  narrow    under 36rem the label sits under the box, one at a time,\n *            with no leader line\n *  smooth    only transform and opacity move, each on its own layer,\n *            so nothing repaints while a panel flies and a busy page\n *            can't stall it\n *  reduced   nothing moves: no fly-in, no lift, no autoplay. An open\n *            face lights its socket and rim and its label fades in\n *            (0.2s); the first face is open at rest\n * ───────────────────────────────────────────────────────── */\n\nconst BOX = {\n  spread: 0.55, // share of an edge a panel starts out from its socket\n  lift: 0.15, // share of an edge an open panel lifts (`lift`)\n  inset: 0.075, // share of an edge between a panel and the frame's rim\n  core: 0.36, // share of an edge the glass core measures\n  rise: 6, // px a label rises into place\n  gap: 18, // px between the box and a label beside it\n  minLabel: 156, // px; the narrowest a label beside the box gets\n} as const;\n\nconst LIGHT = \"var(--bb-light)\";\nconst WIDE = 576; // px (36rem): from here the labels sit beside the box\nconst POSE = \"rotateX(-35.264deg) rotateY(-45deg)\"; // a corner toward you, all three faces equal\nconst AT_REST = \"translateZ(1px)\"; // a shut panel, just above its socket\n\n// The same pose as POSE, for working out where things land on screen.\nconst TILT = -35.264 * (Math.PI / 180);\nconst TURN = -45 * (Math.PI / 180);\n\nconst clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));\n\ntype Vec = [number, number, number];\n\n/** A point on the cube (px from its center, y down), posed and seen through the perspective. */\nfunction project([x, y, z]: Vec, depth: number): [number, number] {\n  const x1 = x * Math.cos(TURN) + z * Math.sin(TURN);\n  const z1 = -x * Math.sin(TURN) + z * Math.cos(TURN);\n  const y2 = y * Math.cos(TILT) - z1 * Math.sin(TILT);\n  const z2 = y * Math.sin(TILT) + z1 * Math.cos(TILT);\n  const k = depth / (depth - z2);\n  return [x1 * k, y2 * k];\n}\n\nexport type BlackBoxSide = \"top\" | \"left\" | \"right\";\nconst SIDES: BlackBoxSide[] = [\"top\", \"left\", \"right\"];\n\n// Where each visible face sits on the cube, which way it faces, its fill, and which side of the\n// box its label goes.\n// `u` and `v` run across the face, for finding its outer edge on screen.\nconst FACE: Record<BlackBoxSide, { place: string; normal: Vec; u: Vec; v: Vec; fill: string; end: boolean }> = {\n  top: { place: \"rotateX(90deg)\", normal: [0, -1, 0], u: [1, 0, 0], v: [0, 0, 1], fill: \"var(--bb-top)\", end: false },\n  left: { place: \"rotateY(0deg)\", normal: [0, 0, 1], u: [1, 0, 0], v: [0, 1, 0], fill: \"var(--bb-left)\", end: false },\n  right: { place: \"rotateY(90deg)\", normal: [1, 0, 0], u: [0, 0, 1], v: [0, 1, 0], fill: \"var(--bb-right)\", end: true },\n};\n// The three faces you can't see: only drawn as glass, where they show through.\nconst HIDDEN = [\"rotateY(180deg)\", \"rotateY(-90deg)\", \"rotateX(-90deg)\"];\nconst CORE = [\"rotateY(0deg)\", \"rotateY(90deg)\", \"rotateY(180deg)\", \"rotateY(-90deg)\", \"rotateX(90deg)\", \"rotateX(-90deg)\"];\n\n// Each material is a set of variables the faces read. Glass draws in currentColor, so it suits\n// light and dark pages alike; `auto` is black in light mode and glass in dark.\nconst SOLID =\n  \"[--bb-top:linear-gradient(150deg,#4a4a55_0%,#1b1b21_42%,#0b0b0e_100%)] [--bb-left:linear-gradient(165deg,#222229_0%,#0c0c10_55%,#050506_100%)] [--bb-right:linear-gradient(195deg,#16161b_0%,#070709_60%,#000_100%)] [--bb-body:#030304] [--bb-line:rgb(255_255_255/0.07)] [--bb-rim:rgb(255_255_255/0.1)] [--bb-icon:rgb(255_255_255/0.3)] [--bb-ghost:0] [--bb-shade:1]\";\nconst GLASS =\n  \"[--bb-top:linear-gradient(150deg,color-mix(in_srgb,currentColor_12%,transparent),color-mix(in_srgb,currentColor_2%,transparent))] [--bb-left:linear-gradient(165deg,color-mix(in_srgb,currentColor_7%,transparent),transparent)] [--bb-right:linear-gradient(195deg,color-mix(in_srgb,currentColor_5%,transparent),transparent)] [--bb-body:transparent] [--bb-line:color-mix(in_srgb,currentColor_22%,transparent)] [--bb-rim:color-mix(in_srgb,currentColor_45%,transparent)] [--bb-icon:color-mix(in_srgb,currentColor_45%,transparent)] [--bb-ghost:1] [--bb-shade:0]\";\nconst AUTO =\n  SOLID +\n  \" dark:[--bb-top:linear-gradient(150deg,color-mix(in_srgb,currentColor_12%,transparent),color-mix(in_srgb,currentColor_2%,transparent))] dark:[--bb-left:linear-gradient(165deg,color-mix(in_srgb,currentColor_7%,transparent),transparent)] dark:[--bb-right:linear-gradient(195deg,color-mix(in_srgb,currentColor_5%,transparent),transparent)] dark:[--bb-body:transparent] dark:[--bb-line:color-mix(in_srgb,currentColor_22%,transparent)] dark:[--bb-rim:color-mix(in_srgb,currentColor_45%,transparent)] dark:[--bb-icon:color-mix(in_srgb,currentColor_45%,transparent)] dark:[--bb-ghost:1] dark:[--bb-shade:0]\";\nconst MATERIAL = { solid: SOLID, glass: GLASS, auto: AUTO } as const;\n\n// Labels draw in the text color around the box, the detail a step quieter, so one `text-*` class\n// themes them on any surface.\nconst MUTED = \"text-[color-mix(in_srgb,currentColor_60%,transparent)]\";\n\n/** Everything about the box that follows from its width (and how far a panel lifts): sizes, and where each label goes. */\nfunction layout(width: number, liftShare: number = BOX.lift) {\n  const wide = width >= WIDE;\n  // Mirrors the stage's CSS height (twice the edge), so nothing jumps when this lands.\n  const size = clamp(width * (wide ? 0.3 : 0.5), 96, wide ? 300 : 240);\n  const half = size / 2;\n  const depth = size * 9;\n  const lift = size * liftShare;\n  const height = size * 2;\n\n  const corners: Vec[] = [];\n  for (const x of [-half, half]) for (const y of [-half, half]) for (const z of [-half, half]) corners.push([x, y, z]);\n  const lifted: Vec[] = [-half, half].flatMap((x) => [-half, half].map((z): Vec => [x, -half - lift, z]));\n  const points = [...corners, ...lifted].map((point) => project(point, depth));\n  const ys = points.map(([, y]) => y);\n  const reach = Math.max(...points.map(([x]) => Math.abs(x))) + lift * 0.75;\n\n  const cx = width / 2;\n  const cy = height / 2 - (Math.min(...ys) + Math.max(...ys)) / 2;\n  const bottom = cy + Math.max(...corners.map((point) => project(point, depth)[1]));\n\n  // Each open face's center (where its light spills from), the point on its outer edge where the\n  // leader line pins (just inside the panel, nearest the label), and the label's edge.\n  const anchors = {} as Record<BlackBoxSide, { x: number; y: number; pin: [number, number]; edge: number }>;\n  for (const side of SIDES) {\n    const { normal, u, v, end } = FACE[side];\n    const d = half + lift;\n    const at = (a: number, b: number): Vec => {\n      const r = (half - size * BOX.inset) * 0.8;\n      return [0, 1, 2].map((i) => normal[i] * d + (u[i] * a + v[i] * b) * r) as Vec;\n    };\n    const [x, y] = project(at(0, 0), depth);\n    let pin: [number, number] = [x, y];\n    let best = -Infinity;\n    for (const [a, b] of [[-1, -1], [1, -1], [1, 1], [-1, 1], [0, -1], [0, 1], [-1, 0], [1, 0]]) {\n      const [px, py] = project(at(a, b), depth);\n      // Furthest toward the label, preferring points level with the face's middle.\n      const score = (end ? px : -px) - Math.abs(py - y) * 0.2;\n      if (score > best) [best, pin] = [score, [px, py]];\n    }\n    anchors[side] = {\n      x: cx + x,\n      y: cy + y,\n      pin: [cx + pin[0], cy + pin[1]],\n      edge: end ? cx + reach + BOX.gap : cx - reach - BOX.gap,\n    };\n  }\n  // A label gets the room beside the box, but never less than `minLabel`: in a tight column it\n  // reaches a little past the box's edge (into a gutter) rather than wrap word by word.\n  const room = clamp(width / 2 - reach - BOX.gap - 4, BOX.minLabel, 240);\n  return { wide, size, half, depth, lift, width, height, cx, cy, bottom, anchors, room };\n}\n\ntype Layout = ReturnType<typeof layout>;\n\nexport interface BlackBoxFace {\n  /** A unique id. `value` and `onValueChange` use it. */\n  id: string;\n  /** What this part of your product is called: the label's title and the face's accessible name. */\n  label: string;\n  /** A line or two on what this part does, or just did. Shown under the label. */\n  detail?: React.ReactNode;\n  /** A small mark engraved in the middle of the panel. It lights up when the face opens. */\n  icon?: React.ReactNode;\n}\n\n/** Why the open face changed: autoplay moved on, the visitor looked (hover, keyboard focus), or picked (click, tap, Enter). */\nexport type BlackBoxReason = \"autoplay\" | \"preview\" | \"pick\";\n\nexport interface BlackBoxProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"defaultValue\" | \"onChange\" | \"children\"> {\n  /** Up to three faces, in the order autoplay opens them: the top, then the left side, then the right. */\n  faces: BlackBoxFace[];\n  /** The open face's id, or `null` for none, when you control it. */\n  value?: string | null;\n  /** The face that's open at first. None by default. */\n  defaultValue?: string | null;\n  /** Called whenever a face opens, or the box shuts (`null`), with the reason: autoplay, a preview (hover or keyboard focus) or a pick. */\n  onValueChange?: (id: string | null, reason: BlackBoxReason) => void;\n  /** Open each face in turn, 3.2s each, with a short rest shut between rounds, until the visitor picks one. A number sets the time per face in ms. */\n  autoplay?: boolean | number;\n  /** What the box is made of: glossy black, a glass wireframe, or `auto`, black in light mode and glass in dark. */\n  material?: \"auto\" | \"solid\" | \"glass\";\n  /** Show the open face's label beside the box on a leader line, or under it in narrow containers. Turn it off when your own copy says which part is open. */\n  labels?: boolean;\n  /** Fly the panels in and shut the box the first time it scrolls into view. */\n  intro?: boolean;\n  /** How far an open panel lifts off its socket, as a share of the box's edge. */\n  lift?: number;\n  /** Seconds an opening panel takes to lift (its spring's visual duration). */\n  liftDuration?: number;\n  /** How much an opening panel overshoots before it settles, from 0 (none) to 1. */\n  liftBounce?: number;\n}\n\nconst subscribeNothing = () => () => {};\n\n/* ── Part refs ─────────────────────────────────────────── */\n\n// Every element a face animates, filled in by callback refs and read only in effects.\ntype Part = \"lifter\" | \"glow\" | \"rim\" | \"sheen\" | \"lit\" | \"spill\" | \"dot\" | \"line\" | \"label\" | \"caption\";\ntype Parts = Map<string, HTMLElement>;\nconst key = (side: BlackBoxSide, part: Part) => `${side}:${part}`;\n\n// The face ids, joined, stand in for the faces in effects; this finds an id's side among them.\nconst SEP = \"\\u0000\";\nconst sideIn = (ids: string, id: string | null) => {\n  const index = id === null ? -1 : ids.split(SEP).indexOf(id);\n  return index < 0 ? null : SIDES[index];\n};\n\n/* ── BlackBox ──────────────────────────────────────────── */\n\nexport function BlackBox({\n  faces,\n  value,\n  defaultValue = null,\n  onValueChange,\n  autoplay = true,\n  material = \"auto\",\n  labels = true,\n  intro = true,\n  lift = BOX.lift,\n  liftDuration = TIMING.open.visualDuration,\n  liftBounce = TIMING.open.bounce,\n  className,\n  style,\n  onPointerEnter,\n  onPointerLeave,\n  onFocus,\n  onBlur,\n  ...props\n}: BlackBoxProps) {\n  const reduced = useReducedMotion() ?? false;\n  const hydrated = React.useSyncExternalStore(subscribeNothing, () => true, () => false);\n\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const stageRef = React.useRef<HTMLDivElement>(null);\n  const cubeRef = React.useRef<HTMLDivElement>(null);\n  const coreRef = React.useRef<HTMLDivElement>(null);\n  const buttons = React.useRef(new Map<string, HTMLButtonElement>());\n  const parts = React.useRef<Parts>(new Map());\n  const part = React.useCallback(\n    (side: BlackBoxSide, name: Part) => (node: HTMLElement | null) => {\n      if (node) parts.current.set(key(side, name), node);\n      else parts.current.delete(key(side, name));\n    },\n    [],\n  );\n\n  const shown = faces.slice(0, 3).map((face, i) => ({ ...face, side: SIDES[i] }));\n  const ids = shown.map((face) => face.id).join(SEP);\n\n  const [width, setWidth] = React.useState(0);\n  const liftShare = clamp(lift, 0, 1);\n  const geo = React.useMemo(() => (width > 0 ? layout(width, liftShare) : null), [width, liftShare]);\n\n  // Who's asking for which face: a hovering mouse, keyboard focus, a pin (click, tap, Enter) or\n  // autoplay. The first one that has an answer wins.\n  const [hover, setHover] = React.useState<string | null>(null);\n  const [focus, setFocus] = React.useState<string | null>(null);\n  const [pinned, setPinned] = React.useState<string | null>(null);\n  const [auto, setAuto] = React.useState<string | null>(defaultValue);\n  const reason = React.useRef<BlackBoxReason>(\"autoplay\");\n\n  const interval = autoplay === true ? TIMING.autoplay : typeof autoplay === \"number\" ? autoplay : 0;\n  const [stopped, setStopped] = React.useState(false);\n  const running = interval > 0 && !stopped && !reduced && shown.length > 0;\n  const [pointerIn, setPointerIn] = React.useState(false);\n  const [focusIn, setFocusIn] = React.useState(false);\n  const [pageHidden, setPageHidden] = React.useState(false);\n  const inView = useInView(rootRef, { amount: 0.4 });\n  const paused = pointerIn || focusIn || pageHidden || !inView;\n  const [shut, setShut] = React.useState(false); // the intro has played\n  const ready = !intro || reduced || shut;\n\n  // Under reduced motion nothing cycles, so the first face rests open where autoplay would have\n  // shown it. Gated on hydration: useReducedMotion is already true in the hydration render.\n  const still = hydrated && reduced && interval > 0 ? (shown[0]?.id ?? null) : null;\n  const internal = hover ?? focus ?? pinned ?? auto ?? still;\n  const requested = value !== undefined ? value : internal;\n  const active = sideIn(ids, requested) ? requested : null;\n\n  const timers = React.useRef({ intent: 0, leave: 0, resume: 0 });\n  React.useEffect(() => {\n    const current = timers.current;\n    return () => {\n      window.clearTimeout(current.intent);\n      window.clearTimeout(current.leave);\n      window.clearTimeout(current.resume);\n    };\n  }, []);\n\n  // Tell the parent when the face the visitor (or autoplay) asked for changes.\n  const onChange = React.useRef(onValueChange);\n  React.useEffect(() => {\n    onChange.current = onValueChange;\n  });\n  const reported = React.useRef(internal);\n  React.useEffect(() => {\n    if (internal === reported.current) return;\n    reported.current = internal;\n    onChange.current?.(internal, reason.current);\n  }, [internal]);\n\n  // Width drives every size; the observer reports once on its own as soon as it's attached.\n  React.useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const observer = new ResizeObserver(([entry]) => setWidth(Math.round(entry.contentRect.width)));\n    observer.observe(root);\n    return () => observer.disconnect();\n  }, []);\n\n  React.useEffect(() => {\n    const update = () => setPageHidden(document.hidden);\n    document.addEventListener(\"visibilitychange\", update);\n    return () => document.removeEventListener(\"visibilitychange\", update);\n  }, []);\n\n  /* ── the box shuts ── */\n\n  const played = React.useRef(false);\n  React.useEffect(() => {\n    const stage = stageRef.current;\n    if (!geo || !stage || played.current) return;\n    if (!intro || reduced) {\n      played.current = true;\n      stage.style.opacity = \"1\";\n      return;\n    }\n    if (!inView) return;\n    played.current = true;\n    stage.style.opacity = \"1\";\n    const far = `translateZ(${geo.size * BOX.spread}px)`;\n    const frame = cubeRef.current?.querySelectorAll<HTMLElement>(\"[data-frame]\") ?? [];\n    frame.forEach((node) => animate(node, { opacity: [0, 1] }, TIMING.reveal));\n    SIDES.forEach((side, i) => {\n      const lifter = parts.current.get(key(side, \"lifter\"));\n      const glow = parts.current.get(key(side, \"glow\"));\n      if (lifter) {\n        const delay = i * TIMING.stagger;\n        animate(lifter, { transform: [far, AT_REST] }, { ...TIMING.assemble, delay });\n        animate(lifter, { opacity: [0, 1] }, { ...TIMING.reveal, delay });\n      }\n      if (glow) animate(glow, { opacity: [0, 0.9, 0] }, { ...TIMING.flash, delay: TIMING.flashAfter + i * TIMING.flashStagger });\n    });\n    const timer = window.setTimeout(() => setShut(true), TIMING.shut);\n    return () => window.clearTimeout(timer);\n  }, [geo, intro, reduced, inView]);\n\n  /* ── a face opens, another closes ── */\n\n  const openRef = React.useRef<string | null>(null);\n  const turns = React.useRef(0);\n  const sizeRef = React.useRef(\"\");\n\n  const place = React.useCallback(\n    (side: BlackBoxSide, open: boolean, g: Layout, instant: boolean) => {\n      const get = (name: Part) => parts.current.get(key(side, name));\n      const lifter = get(\"lifter\");\n      const quick = instant || reduced;\n      const fade = quick ? TIMING.fade : open ? TIMING.show : TIMING.hide;\n      const to = (node: HTMLElement | undefined, opacity: number, options: object = fade) => {\n        if (node) animate(node, { opacity }, instant ? { duration: 0 } : options);\n      };\n\n      if (lifter) {\n        const transform = open && !reduced ? `translateZ(${g.lift}px)` : AT_REST;\n        const opening = { ...TIMING.open, visualDuration: Math.max(0.01, liftDuration), bounce: clamp(liftBounce, 0, 1) };\n        animate(lifter, { transform }, instant ? { duration: 0 } : open ? opening : TIMING.close);\n      }\n      to(get(\"glow\"), open ? 1 : 0);\n      to(get(\"rim\"), open ? 1 : 0);\n      to(get(\"lit\"), open ? 1 : 0);\n      to(get(\"spill\"), open ? 1 : 0, quick ? TIMING.fade : open ? TIMING.spill : TIMING.hide);\n\n      const dot = get(\"dot\");\n      const line = get(\"line\");\n      const label = get(\"label\");\n      const caption = get(\"caption\");\n      if (!open || quick) {\n        [dot, line, label, caption].forEach((node) => {\n          if (!node) return;\n          if (open) node.style.transform = \"none\";\n          to(node, open ? 1 : 0);\n        });\n        return;\n      }\n      const sheen = get(\"sheen\");\n      if (sheen) animate(sheen, { transform: [\"translateX(0%)\", \"translateX(330%)\"] }, TIMING.sheen);\n      if (dot) animate(dot, { opacity: [0, 1], transform: [\"scale(0.3)\", \"scale(1)\"] }, { ...TIMING.show, delay: TIMING.lead });\n      if (line) animate(line, { opacity: [1, 1], transform: [\"scaleX(0)\", \"scaleX(1)\"] }, { ...TIMING.draw, delay: TIMING.lead });\n      const rise = [`translateY(${BOX.rise}px)`, \"translateY(0px)\"];\n      if (label) animate(label, { opacity: [0, 1], transform: rise }, { ...TIMING.show, delay: TIMING.lead + TIMING.labelLag });\n      if (caption) animate(caption, { opacity: [0, 1], transform: rise }, { ...TIMING.show, delay: TIMING.lead });\n    },\n    [reduced, liftDuration, liftBounce],\n  );\n\n  React.useEffect(() => {\n    if (!geo || !ready) return;\n    // A new size (or lift) re-places whatever is open without animating.\n    const shape = `${geo.size}:${geo.lift}`;\n    const resized = sizeRef.current !== \"\" && sizeRef.current !== shape;\n    sizeRef.current = shape;\n    const before = openRef.current;\n    if (before === active && !resized) return;\n    openRef.current = active;\n\n    const was = sideIn(ids, before);\n    const now = sideIn(ids, active);\n    if (resized && was === now) {\n      if (now) place(now, true, geo, true);\n      return;\n    }\n    if (was) place(was, false, geo, resized);\n    if (now) place(now, true, geo, false);\n\n    // The glass core, a smaller box inside the box, turns a quarter with each face that opens.\n    const core = coreRef.current;\n    if (now && core && !reduced) {\n      turns.current += 1;\n      animate(core, { transform: `rotateY(${turns.current * 90}deg)` }, TIMING.turn);\n    }\n  }, [active, geo, ready, reduced, place, ids]);\n\n  // A new material swaps under a flash of light through the seams, so the change reads as the\n  // box lighting up rather than a cut.\n  const material0 = React.useRef(material);\n  React.useEffect(() => {\n    if (material0.current === material) return;\n    material0.current = material;\n    if (reduced) return;\n    SIDES.forEach((side, i) => {\n      const glow = parts.current.get(key(side, \"glow\"));\n      if (glow && sideIn(ids, active) !== side) animate(glow, { opacity: [0.9, 0] }, { ...TIMING.flash, delay: i * TIMING.flashStagger });\n    });\n  }, [material, reduced, active, ids]);\n\n  /* ── autoplay ── */\n\n  // Faces in order, then a rest with the box shut. It moves on from whichever face is showing, so\n  // a parent that also sets `value` (from a list beside the box, say) stays in step. The very\n  // first face comes up right after the box has shut, so there's something to read at once.\n  const started = React.useRef(false);\n  React.useEffect(() => {\n    if (!running || paused || !ready) return;\n    const order: (string | null)[] = [...ids.split(SEP), null];\n    const at = order.indexOf(active);\n    const delay = active === null ? (started.current ? interval * TIMING.rest : TIMING.first) : interval;\n    const timer = window.setTimeout(() => {\n      const next = order[(at + 1) % order.length];\n      started.current = true;\n      reason.current = \"autoplay\";\n      // Reported here rather than from the change, since a controlled box can be asked for the\n      // face its own autoplay already holds, and that wouldn't count as a change.\n      reported.current = next;\n      onChange.current?.(next, \"autoplay\");\n      setAuto(next);\n    }, delay);\n    return () => window.clearTimeout(timer);\n  }, [running, paused, ready, active, interval, ids]);\n\n  /* ── pins close on a tap outside ── */\n\n  React.useEffect(() => {\n    if (pinned === null) return;\n    const away = (event: PointerEvent) => {\n      if (rootRef.current?.contains(event.target as Node)) return;\n      reason.current = \"pick\";\n      setPinned(null);\n    };\n    document.addEventListener(\"pointerdown\", away);\n    return () => document.removeEventListener(\"pointerdown\", away);\n  }, [pinned]);\n\n  /* ── handlers ── */\n\n  const pick = (id: string) => {\n    reason.current = \"pick\";\n    setStopped(true);\n    setAuto(null);\n    if (pinned === id) {\n      setPinned(null);\n      setHover(null);\n      setFocus(null);\n    } else {\n      setPinned(id);\n    }\n  };\n\n  const enterFace = (event: React.PointerEvent, id: string) => {\n    if (event.pointerType !== \"mouse\") return;\n    window.clearTimeout(timers.current.intent);\n    timers.current.intent = window.setTimeout(() => {\n      reason.current = \"preview\";\n      setHover(id);\n    }, TIMING.intent);\n  };\n\n  const leaveFace = (event: React.PointerEvent) => {\n    if (event.pointerType === \"mouse\") window.clearTimeout(timers.current.intent);\n  };\n\n  const enterBox = (event: React.PointerEvent<HTMLDivElement>) => {\n    onPointerEnter?.(event);\n    if (event.pointerType !== \"mouse\") return;\n    window.clearTimeout(timers.current.leave);\n    window.clearTimeout(timers.current.resume);\n    setPointerIn(true);\n  };\n\n  const leaveBox = (event: React.PointerEvent<HTMLDivElement>) => {\n    onPointerLeave?.(event);\n    if (event.pointerType !== \"mouse\") return;\n    window.clearTimeout(timers.current.intent);\n    if (running) {\n      // Autoplay carries on from the face that was left open, so nothing changes as you leave.\n      if (hover !== null) setAuto(hover);\n      setHover(null);\n      timers.current.resume = window.setTimeout(() => setPointerIn(false), TIMING.resume);\n      return;\n    }\n    setPointerIn(false);\n    timers.current.leave = window.setTimeout(() => {\n      reason.current = \"preview\";\n      setHover(null);\n    }, TIMING.linger);\n  };\n\n  const onKeyDown = (event: React.KeyboardEvent, index: number) => {\n    const list = shown;\n    let next = -1;\n    if (event.key === \"ArrowRight\" || event.key === \"ArrowDown\") next = (index + 1) % list.length;\n    else if (event.key === \"ArrowLeft\" || event.key === \"ArrowUp\") next = (index - 1 + list.length) % list.length;\n    else if (event.key === \"Home\") next = 0;\n    else if (event.key === \"End\") next = list.length - 1;\n    else if (event.key === \"Escape\") {\n      event.preventDefault();\n      reason.current = \"pick\";\n      setStopped(true);\n      setPinned(null);\n      setFocus(null);\n      setHover(null);\n      setAuto(null);\n      return;\n    }\n    if (next < 0) return;\n    event.preventDefault();\n    buttons.current.get(list[next].id)?.focus();\n  };\n\n  const tabStop = active ?? shown[0]?.id;\n  const g = geo;\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"group\"\n      className={cn(\"relative isolate w-full @container/black-box\", MATERIAL[material], className)}\n      style={{ \"--bb-light\": \"var(--black-box-light, #4f6bff)\", ...style } as React.CSSProperties}\n      onPointerEnter={enterBox}\n      onPointerLeave={leaveBox}\n      onFocus={(event) => {\n        onFocus?.(event);\n        setFocusIn(true);\n      }}\n      onBlur={(event) => {\n        onBlur?.(event);\n        if (!event.currentTarget.contains(event.relatedTarget as Node)) setFocusIn(false);\n      }}\n      {...props}\n    >\n      <div\n        ref={stageRef}\n        style={{ opacity: 0 }}\n        className=\"relative h-[calc(2*clamp(96px,50cqw,240px))] @[36rem]/black-box:h-[calc(2*clamp(96px,30cqw,300px))]\"\n      >\n        {g && (\n          <>\n            {/* Behind the box: its shadow, the glass glow, and the light each open face spills. */}\n            <div aria-hidden className=\"pointer-events-none absolute inset-0\">\n              <div\n                className=\"absolute rounded-[50%]\"\n                style={{\n                  left: g.cx - g.size * 1.1,\n                  top: g.cy - g.size * 0.8,\n                  width: g.size * 2.2,\n                  height: g.size * 1.9,\n                  opacity: \"var(--bb-shade)\",\n                  background: \"radial-gradient(closest-side, rgb(0 0 0 / 0.09), transparent)\",\n                }}\n              />\n              <div\n                className=\"absolute rounded-[50%]\"\n                style={{\n                  left: g.cx - g.size * 0.62,\n                  top: g.bottom - g.size * 0.09,\n                  width: g.size * 1.24,\n                  height: g.size * 0.24,\n                  opacity: \"var(--bb-shade)\",\n                  background: \"radial-gradient(closest-side, rgb(0 0 0 / 0.28), transparent)\",\n                }}\n              />\n              <div\n                className=\"absolute rounded-[50%]\"\n                style={{\n                  left: g.cx - g.size,\n                  top: g.cy - g.size,\n                  width: g.size * 2,\n                  height: g.size * 2,\n                  opacity: \"var(--bb-ghost)\",\n                  background: `radial-gradient(closest-side, color-mix(in srgb, ${LIGHT} 16%, transparent), transparent)`,\n                }}\n              />\n              {shown.map(({ side }) => (\n                <div\n                  key={side}\n                  ref={part(side, \"spill\")}\n                  className=\"absolute rounded-[50%] will-change-[opacity]\"\n                  style={{\n                    left: g.anchors[side].x - g.size * 0.85,\n                    top: g.anchors[side].y - g.size * 0.85,\n                    width: g.size * 1.7,\n                    height: g.size * 1.7,\n                    opacity: 0,\n                    background: `radial-gradient(closest-side, color-mix(in srgb, ${LIGHT} 42%, transparent), transparent)`,\n                  }}\n                />\n              ))}\n            </div>\n\n            {/* The box: a zero-size point at its center, in perspective, holding the posed cube. */}\n            <div\n              className=\"absolute size-0\"\n              style={{ left: g.cx, top: g.cy, perspective: g.depth }}\n            >\n              <div ref={cubeRef} className=\"absolute size-0 transform-3d\" style={{ transform: POSE }}>\n                {HIDDEN.map((placeFace) => (\n                  <div\n                    key={placeFace}\n                    aria-hidden\n                    className=\"pointer-events-none absolute\"\n                    style={{\n                      left: -g.half,\n                      top: -g.half,\n                      width: g.size,\n                      height: g.size,\n                      opacity: \"var(--bb-ghost)\",\n                      boxShadow: \"inset 0 0 0 1px var(--bb-line)\",\n                      transform: `${placeFace} translateZ(${g.half}px)`,\n                    }}\n                  />\n                ))}\n\n                <div\n                  ref={coreRef}\n                  aria-hidden\n                  className=\"pointer-events-none absolute size-0 transform-3d will-change-transform\"\n                  style={{ transform: \"rotateY(0deg)\", opacity: \"var(--bb-ghost)\" }}\n                >\n                  {CORE.map((placeFace) => (\n                    <div\n                      key={placeFace}\n                      className=\"absolute\"\n                      style={{\n                        left: (-g.size * BOX.core) / 2,\n                        top: (-g.size * BOX.core) / 2,\n                        width: g.size * BOX.core,\n                        height: g.size * BOX.core,\n                        transform: `${placeFace} translateZ(${(g.size * BOX.core) / 2}px)`,\n                        background: `color-mix(in srgb, ${LIGHT} 14%, transparent)`,\n                        boxShadow: `inset 0 0 0 1px ${LIGHT}, 0 0 ${g.size * 0.08}px color-mix(in srgb, ${LIGHT} 45%, transparent)`,\n                      }}\n                    />\n                  ))}\n                </div>\n\n                {SIDES.map((side, index) => {\n                  const face = shown[index];\n                  const inset = g.size * BOX.inset;\n                  const open = face !== undefined && face.id === active;\n                  const panelClass = \"absolute overflow-hidden rounded-[6%] will-change-transform\";\n                  const panelStyle: React.CSSProperties = {\n                    left: inset,\n                    top: inset,\n                    width: g.size - inset * 2,\n                    height: g.size - inset * 2,\n                    transform: AT_REST,\n                    outlineColor: LIGHT,\n                  };\n                  const inside = (\n                    <>\n                      <span aria-hidden className=\"absolute inset-0\" style={{ background: FACE[side].fill }} />\n                      <span\n                        aria-hidden\n                        className=\"absolute inset-0 rounded-[inherit]\"\n                        style={{ boxShadow: \"inset 0 0 0 1px var(--bb-rim), inset 0 1px 0 rgb(255 255 255 / 0.06)\" }}\n                      />\n                      <span\n                        ref={part(side, \"rim\")}\n                        aria-hidden\n                        className=\"absolute inset-0 rounded-[inherit] will-change-[opacity]\"\n                        style={{\n                          opacity: 0,\n                          boxShadow: `inset 0 0 0 1px ${LIGHT}, inset 0 0 ${g.size * 0.14}px -${g.size * 0.03}px ${LIGHT}`,\n                        }}\n                      />\n                      <span\n                        ref={part(side, \"sheen\")}\n                        aria-hidden\n                        className=\"absolute -inset-y-1/4 -left-1/2 w-1/2 -skew-x-12 bg-linear-to-r from-transparent via-white/15 to-transparent will-change-transform\"\n                      />\n                      {face?.icon && (\n                        <span\n                          aria-hidden\n                          className=\"absolute inset-0 grid place-items-center [&_svg]:size-full\"\n                        >\n                          <span className=\"relative\" style={{ width: g.size * 0.2, height: g.size * 0.2 }}>\n                            <span className=\"absolute inset-0\" style={{ color: \"var(--bb-icon)\" }}>\n                              {face.icon}\n                            </span>\n                            <span\n                              ref={part(side, \"lit\")}\n                              className=\"absolute inset-0 will-change-[opacity]\"\n                              style={{ color: LIGHT, opacity: 0 }}\n                            >\n                              {face.icon}\n                            </span>\n                          </span>\n                        </span>\n                      )}\n                    </>\n                  );\n\n                  return (\n                    <div\n                      key={side}\n                      className=\"absolute transform-3d\"\n                      style={{\n                        left: -g.half,\n                        top: -g.half,\n                        width: g.size,\n                        height: g.size,\n                        transform: `${FACE[side].place} translateZ(${g.half}px)`,\n                      }}\n                    >\n                      <div\n                        data-frame\n                        aria-hidden\n                        className=\"pointer-events-none absolute inset-0\"\n                        style={{ background: \"var(--bb-body)\", boxShadow: \"inset 0 0 0 1px var(--bb-line)\" }}\n                      />\n                      <div\n                        ref={part(side, \"glow\")}\n                        aria-hidden\n                        className=\"pointer-events-none absolute will-change-[opacity]\"\n                        style={{\n                          inset: inset * 0.4,\n                          opacity: 0,\n                          borderRadius: \"6%\",\n                          background: `radial-gradient(closest-side, color-mix(in srgb, ${LIGHT} 72%, white), color-mix(in srgb, ${LIGHT} 85%, transparent))`,\n                        }}\n                      />\n                      {face ? (\n                        <button\n                          ref={(node) => {\n                            part(side, \"lifter\")(node);\n                            if (node) buttons.current.set(face.id, node);\n                            else buttons.current.delete(face.id);\n                          }}\n                          type=\"button\"\n                          aria-pressed={open}\n                          tabIndex={face.id === tabStop ? 0 : -1}\n                          className={cn(panelClass, \"cursor-pointer outline-offset-[-3px] focus-visible:outline-2\")}\n                          style={panelStyle}\n                          onClick={() => pick(face.id)}\n                          onPointerEnter={(event) => enterFace(event, face.id)}\n                          onPointerLeave={leaveFace}\n                          onFocus={(event) => {\n                            if (!event.currentTarget.matches(\":focus-visible\")) return;\n                            reason.current = \"preview\";\n                            setFocus(face.id);\n                          }}\n                          onBlur={() => setFocus(null)}\n                          onKeyDown={(event) => onKeyDown(event, index)}\n                        >\n                          {inside}\n                          <span className=\"sr-only\">\n                            {face.label}\n                            {face.detail ? <>: {face.detail}</> : null}\n                          </span>\n                        </button>\n                      ) : (\n                        <div ref={part(side, \"lifter\")} aria-hidden className={panelClass} style={panelStyle}>\n                          {inside}\n                        </div>\n                      )}\n                    </div>\n                  );\n                })}\n              </div>\n            </div>\n\n            {/* Leader lines and labels beside the box, from 36rem up. */}\n            {labels && g.wide && (\n              <div aria-hidden className=\"pointer-events-none absolute inset-0\">\n                {shown.map(({ id, side, label, detail }) => {\n                  const anchor = g.anchors[side];\n                  const [pinX, pinY] = anchor.pin;\n                  const end = FACE[side].end;\n                  const from = Math.min(pinX, anchor.edge + (end ? -6 : 6));\n                  const to = Math.max(pinX, anchor.edge + (end ? -6 : 6));\n                  return (\n                    <React.Fragment key={id}>\n                      <span\n                        ref={part(side, \"line\")}\n                        className=\"absolute h-px will-change-transform\"\n                        style={{\n                          left: from,\n                          top: pinY - 0.5,\n                          width: to - from,\n                          opacity: 0,\n                          transformOrigin: end ? \"left center\" : \"right center\",\n                          background: end\n                            ? `linear-gradient(to right, ${LIGHT}, color-mix(in srgb, ${LIGHT} 35%, transparent))`\n                            : `linear-gradient(to left, ${LIGHT}, color-mix(in srgb, ${LIGHT} 35%, transparent))`,\n                        }}\n                      />\n                      <span\n                        ref={part(side, \"dot\")}\n                        className=\"absolute size-2 rounded-full will-change-transform\"\n                        style={{\n                          left: pinX - 4,\n                          top: pinY - 4,\n                          opacity: 0,\n                          background: `color-mix(in srgb, ${LIGHT} 70%, white)`,\n                          boxShadow: `0 0 0 3px color-mix(in srgb, ${LIGHT} 35%, transparent), 0 0 14px 2px ${LIGHT}`,\n                        }}\n                      />\n                      <div\n                        ref={part(side, \"label\")}\n                        className={cn(\"absolute will-change-transform\", end ? \"text-left\" : \"text-right\")}\n                        style={{\n                          top: pinY - 9,\n                          maxWidth: g.room,\n                          opacity: 0,\n                          ...(end ? { left: anchor.edge } : { right: g.width - anchor.edge }),\n                        }}\n                      >\n                        <p className=\"text-[13px] leading-[18px] font-medium tracking-[-0.01em]\">{label}</p>\n                        {detail ? <p className={cn(\"mt-1 text-xs leading-[17px] text-pretty\", MUTED)}>{detail}</p> : null}\n                      </div>\n                    </React.Fragment>\n                  );\n                })}\n              </div>\n            )}\n          </>\n        )}\n      </div>\n\n      {/* Under 36rem the label sits under the box instead, one at a time in the same spot. */}\n      {labels && (\n        <div aria-hidden className=\"pointer-events-none grid justify-items-center px-4 text-center @[36rem]/black-box:hidden\">\n          {shown.map(({ id, side, label, detail }) => (\n            <div\n              key={id}\n              ref={part(side, \"caption\")}\n              className=\"col-start-1 row-start-1 max-w-72 will-change-transform\"\n              style={{ opacity: 0 }}\n            >\n              <p className=\"text-sm font-medium tracking-[-0.01em]\">{label}</p>\n              {detail ? <p className={cn(\"mt-1 text-[13px] leading-5 text-pretty\", MUTED)}>{detail}</p> : null}\n            </div>\n          ))}\n        </div>\n      )}\n    </div>\n  );\n}\n"
    }
  ]
}