Black Box
3D Product Explainer Box for React
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.
- 3D
- Hero
- AI
- Diagram
Uses motion, clsx, tailwind-merge
<BlackBox />replays the demo when you let go.
"use client";
import { BlackBox, type BlackBoxFace } from "@/components/ui/black-box";
// A support agent's decision, opened up: what it read, how it reasoned, what it did.
const FACES: BlackBoxFace[] = [
{
id: "read",
label: "Read",
detail: "Order #4471, the refund policy and two earlier tickets from the same customer.",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
<path d="M4 5.5A1.5 1.5 0 0 1 5.5 4H11v16H5.5A1.5 1.5 0 0 1 4 18.5z" />
<path d="M20 5.5A1.5 1.5 0 0 0 18.5 4H13v16h5.5a1.5 1.5 0 0 0 1.5-1.5z" />
</svg>
),
},
{
id: "reason",
label: "Reason",
detail: "Delivered 9 days ago, inside the 14-day window. Item unopened, so no restocking fee.",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
<circle cx="6" cy="6" r="2" />
<circle cx="18" cy="12" r="2" />
<circle cx="6" cy="18" r="2" />
<path d="M8 6h3a3 3 0 0 1 3 3v0a3 3 0 0 0 3 3M8 18h3a3 3 0 0 0 3-3v0a3 3 0 0 1 3-3" />
</svg>
),
},
{
id: "act",
label: "Act",
detail: "Refunded $48.00 to the original card and replied in the customer's language.",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12.5l4.5 4.5L19 7.5" />
</svg>
),
},
];
export function BlackBoxDemo() {
return (
<div className="w-full max-w-3xl">
<div className="mx-auto flex w-fit items-center gap-2.5 rounded-full bg-white py-1.5 pr-4 pl-1.5 text-[13px] text-zinc-600 shadow-sm ring-1 ring-zinc-950/[0.07] dark:bg-zinc-900 dark:text-zinc-300 dark:ring-white/10">
<span className="grid size-6 place-items-center rounded-full bg-zinc-950 text-[11px] font-semibold text-white dark:bg-white dark:text-zinc-950">
M
</span>
<span>
<span className="font-medium text-zinc-950 dark:text-white">Why did order #4471 get a refund?</span>
</span>
</div>
<BlackBox faces={FACES} className="mt-2" aria-label="How the support agent decided" />
</div>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Black Box: 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. This demo, Agent Decision: A support agent's refund, opened up: what it read, how it reasoned and what it did, one face at a time. 1. Install it: npx shadcn@latest add @keyframeui/black-box-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/black-box-demo.json instead.) It adds the demo, components/black-box-demo.tsx, and the component, components/ui/black-box.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <BlackBox> on its own. 3. These props set its feel. Change them before you change the file: - material (default "auto"): What the box is made of: glossy black, a glass wireframe, or `auto`, black in light mode and glass in dark. - intro (default true): Fly the panels in and shut the box the first time it scrolls into view. - lift (default 0.15): How far an open panel lifts off its socket, as a share of the box's edge. - liftDuration (default 0.42): How long an opening panel takes to lift, in seconds (its spring's visual duration). - liftBounce (default 0.16): How much an opening panel overshoots before it settles, from 0 (not at all) to 1. 4. Don't change, in components/ui/black-box.tsx: - Panels, lights, lines and labels animate only transform and opacity, through `animate()` on the nodes kept in `parts`, each on its own layer, so nothing repaints while a panel flies. - Requests for a face resolve in one order: hover, then keyboard focus, then a pin, then autoplay. A controlled `value` overrides them all. - A mouse opens a face only after resting on it for the hover intent, and the face stays open while the pointer is anywhere over the box; leaving waits before it closes. - Autoplay holds while the pointer is over the box, focus is inside it, the tab is hidden or the box is under 40% in view, then picks up from the face left open. A click or Esc stops it for good. - Reduced motion has no fly-in, lift, turn or autoplay: every change fades, and the first face rests open once hydrated, since `useReducedMotion` is already true in the hydration render. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/black-box.md
Installation
Add the component on its own with the CLI, or copy the file in by hand.
The CLI writes components/ui/black-box.tsx and installs motion, clsx and tailwind-merge. It needs the @keyframeui registry in components.json; add it once per project.
npx shadcn@latest add @keyframeui/black-boxInstall the dependencies
npm i motion clsx tailwind-mergeAdd the cn helper
Skip this if you already use shadcn/ui.
lib/utils.tsimport { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }Copy the source into your project
components/ui/black-box.tsx"use client"; import * as React from "react"; import { animate, useInView, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; /** * Keep: * - Panels, lights, lines and labels animate only transform and opacity, through `animate()` on * the nodes kept in `parts`, each on its own layer, so nothing repaints while a panel flies. * - Requests for a face resolve in one order: hover, then keyboard focus, then a pin, then * autoplay. A controlled `value` overrides them all. * - A mouse opens a face only after resting on it for the hover intent, and the face stays open * while the pointer is anywhere over the box; leaving waits before it closes. * - Autoplay holds while the pointer is over the box, focus is inside it, the tab is hidden or * the box is under 40% in view, then picks up from the face left open. A click or Esc stops it * for good. * - Reduced motion has no fly-in, lift, turn or autoplay: every change fades, and the first face * rests open once hydrated, since `useReducedMotion` is already true in the hydration render. */ const TIMING = { /** The panels flying in to shut the box. */ assemble: { type: "spring", visualDuration: 0.7, bounce: 0.12 }, /** Between panels flying in, in seconds. */ stagger: 0.07, /** The frame appearing under the panels, and each panel fading in as it flies. */ reveal: { duration: 0.35, ease: [0.23, 1, 0.32, 1] }, /** Light through the seams as the box shuts, or as its material changes. */ flash: { duration: 0.9, ease: "easeOut" }, /** After the panels start flying in, before the seams flash, in seconds. */ flashAfter: 0.42, /** Between the faces' flashes, in seconds. */ flashStagger: 0.04, /** After the panels start flying in, before the box counts as shut and a face can open, in ms. */ shut: 950, /** A panel lifting off its socket (the `liftDuration` and `liftBounce` props). */ open: { type: "spring", visualDuration: 0.42, bounce: 0.16 }, /** A panel settling back in. */ close: { type: "spring", visualDuration: 0.3, bounce: 0 }, /** The glass core turning a quarter to follow. */ turn: { type: "spring", visualDuration: 0.6, bounce: 0.06 }, /** The highlight crossing an opening panel. */ sheen: { duration: 0.8, ease: [0.23, 1, 0.32, 1] }, /** The panel leading its dot, line and caption, in seconds. */ lead: 0.08, /** The label trailing its dot, in seconds. */ labelLag: 0.06, /** The leader line drawing out from the dot. */ draw: { duration: 0.28, ease: [0.23, 1, 0.32, 1] }, /** Label, dot and light coming in. */ show: { duration: 0.22, ease: [0.23, 1, 0.32, 1] }, /** Light spreading onto the page. */ spill: { duration: 0.4, ease: [0.23, 1, 0.32, 1] }, /** Label, line and light going, faster than they came. */ hide: { duration: 0.15, ease: [0.23, 1, 0.32, 1] }, /** Reduced motion: every change is a fade. */ fade: { duration: 0.2, ease: [0.23, 1, 0.32, 1] }, /** A mouse resting on a face before it opens, in ms. */ intent: 80, /** An open face waiting after the pointer leaves the box, in ms. */ linger: 250, /** After the pointer leaves, before autoplay picks up, in ms. */ resume: 700, /** Each face staying open under `autoplay={true}`, in ms. */ autoplay: 3200, /** The box resting shut between rounds, as a share of the time per face. */ rest: 0.5, /** Before the first face opens once the box has shut, in ms. */ first: 300, } as const; /* ───────────────────────────────────────────────────────── * BLACK BOX STORYBOARD * * rest a glossy black cube seen from above one corner, so it * reads as a hexagon: three panels (top, left, right) set * into a darker frame, a soft shadow under it. As glass * (the dark-mode default) it's a wireframe in light lines, * the hidden edges showing through and a small lit core * hanging inside * view the first time it's 40% in view the panels come in from * 55% of an edge out along their faces, 70ms apart, on a * 0.7s spring with a little give, and shut. As they land, * light flashes through the seams once (0.9s) * open a face opens: its panel lifts 15% of an edge off its * socket on a 0.42s spring with a little give, the socket * lights up in the accent, the light spills onto the page * around the box and a sheen crosses the panel. 80ms later * a dot lands on the panel, a hairline draws out to the * label (0.28s) and the label rises 6px into place * close the panel settles back in 0.3s with no bounce while the * label, line and light go in 0.15s, faster than they came * autoplay each face opens for 3.2s in turn, the core inside turning * a quarter to follow, then the box rests shut for half * that and starts again. A pointer over the box, focus * inside it, a hidden tab or the box out of view holds it; * leaving picks up 0.7s later from the face left open. A * click stops it for good * point with a mouse, resting 80ms on a face opens it. It stays * open while the pointer is anywhere over the box and * closes 250ms after it leaves * touch a tap opens a face and keeps it open; a second tap, Esc * or a tap outside closes it * keys the faces are buttons behind one tab stop. Arrows move * between them and open each, Enter or Space pins one, * Esc closes * narrow under 36rem the label sits under the box, one at a time, * with no leader line * smooth only transform and opacity move, each on its own layer, * so nothing repaints while a panel flies and a busy page * can't stall it * reduced nothing moves: no fly-in, no lift, no autoplay. An open * face lights its socket and rim and its label fades in * (0.2s); the first face is open at rest * ───────────────────────────────────────────────────────── */ const BOX = { spread: 0.55, // share of an edge a panel starts out from its socket lift: 0.15, // share of an edge an open panel lifts (`lift`) inset: 0.075, // share of an edge between a panel and the frame's rim core: 0.36, // share of an edge the glass core measures rise: 6, // px a label rises into place gap: 18, // px between the box and a label beside it minLabel: 156, // px; the narrowest a label beside the box gets } as const; const LIGHT = "var(--bb-light)"; const WIDE = 576; // px (36rem): from here the labels sit beside the box const POSE = "rotateX(-35.264deg) rotateY(-45deg)"; // a corner toward you, all three faces equal const AT_REST = "translateZ(1px)"; // a shut panel, just above its socket // The same pose as POSE, for working out where things land on screen. const TILT = -35.264 * (Math.PI / 180); const TURN = -45 * (Math.PI / 180); const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); type Vec = [number, number, number]; /** A point on the cube (px from its center, y down), posed and seen through the perspective. */ function project([x, y, z]: Vec, depth: number): [number, number] { const x1 = x * Math.cos(TURN) + z * Math.sin(TURN); const z1 = -x * Math.sin(TURN) + z * Math.cos(TURN); const y2 = y * Math.cos(TILT) - z1 * Math.sin(TILT); const z2 = y * Math.sin(TILT) + z1 * Math.cos(TILT); const k = depth / (depth - z2); return [x1 * k, y2 * k]; } export type BlackBoxSide = "top" | "left" | "right"; const SIDES: BlackBoxSide[] = ["top", "left", "right"]; // Where each visible face sits on the cube, which way it faces, its fill, and which side of the // box its label goes. // `u` and `v` run across the face, for finding its outer edge on screen. const FACE: Record<BlackBoxSide, { place: string; normal: Vec; u: Vec; v: Vec; fill: string; end: boolean }> = { top: { place: "rotateX(90deg)", normal: [0, -1, 0], u: [1, 0, 0], v: [0, 0, 1], fill: "var(--bb-top)", end: false }, left: { place: "rotateY(0deg)", normal: [0, 0, 1], u: [1, 0, 0], v: [0, 1, 0], fill: "var(--bb-left)", end: false }, right: { place: "rotateY(90deg)", normal: [1, 0, 0], u: [0, 0, 1], v: [0, 1, 0], fill: "var(--bb-right)", end: true }, }; // The three faces you can't see: only drawn as glass, where they show through. const HIDDEN = ["rotateY(180deg)", "rotateY(-90deg)", "rotateX(-90deg)"]; const CORE = ["rotateY(0deg)", "rotateY(90deg)", "rotateY(180deg)", "rotateY(-90deg)", "rotateX(90deg)", "rotateX(-90deg)"]; // Each material is a set of variables the faces read. Glass draws in currentColor, so it suits // light and dark pages alike; `auto` is black in light mode and glass in dark. const SOLID = "[--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]"; const GLASS = "[--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]"; const AUTO = SOLID + " 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]"; const MATERIAL = { solid: SOLID, glass: GLASS, auto: AUTO } as const; // Labels draw in the text color around the box, the detail a step quieter, so one `text-*` class // themes them on any surface. const MUTED = "text-[color-mix(in_srgb,currentColor_60%,transparent)]"; /** Everything about the box that follows from its width (and how far a panel lifts): sizes, and where each label goes. */ function layout(width: number, liftShare: number = BOX.lift) { const wide = width >= WIDE; // Mirrors the stage's CSS height (twice the edge), so nothing jumps when this lands. const size = clamp(width * (wide ? 0.3 : 0.5), 96, wide ? 300 : 240); const half = size / 2; const depth = size * 9; const lift = size * liftShare; const height = size * 2; const corners: Vec[] = []; for (const x of [-half, half]) for (const y of [-half, half]) for (const z of [-half, half]) corners.push([x, y, z]); const lifted: Vec[] = [-half, half].flatMap((x) => [-half, half].map((z): Vec => [x, -half - lift, z])); const points = [...corners, ...lifted].map((point) => project(point, depth)); const ys = points.map(([, y]) => y); const reach = Math.max(...points.map(([x]) => Math.abs(x))) + lift * 0.75; const cx = width / 2; const cy = height / 2 - (Math.min(...ys) + Math.max(...ys)) / 2; const bottom = cy + Math.max(...corners.map((point) => project(point, depth)[1])); // Each open face's center (where its light spills from), the point on its outer edge where the // leader line pins (just inside the panel, nearest the label), and the label's edge. const anchors = {} as Record<BlackBoxSide, { x: number; y: number; pin: [number, number]; edge: number }>; for (const side of SIDES) { const { normal, u, v, end } = FACE[side]; const d = half + lift; const at = (a: number, b: number): Vec => { const r = (half - size * BOX.inset) * 0.8; return [0, 1, 2].map((i) => normal[i] * d + (u[i] * a + v[i] * b) * r) as Vec; }; const [x, y] = project(at(0, 0), depth); let pin: [number, number] = [x, y]; let best = -Infinity; for (const [a, b] of [[-1, -1], [1, -1], [1, 1], [-1, 1], [0, -1], [0, 1], [-1, 0], [1, 0]]) { const [px, py] = project(at(a, b), depth); // Furthest toward the label, preferring points level with the face's middle. const score = (end ? px : -px) - Math.abs(py - y) * 0.2; if (score > best) [best, pin] = [score, [px, py]]; } anchors[side] = { x: cx + x, y: cy + y, pin: [cx + pin[0], cy + pin[1]], edge: end ? cx + reach + BOX.gap : cx - reach - BOX.gap, }; } // A label gets the room beside the box, but never less than `minLabel`: in a tight column it // reaches a little past the box's edge (into a gutter) rather than wrap word by word. const room = clamp(width / 2 - reach - BOX.gap - 4, BOX.minLabel, 240); return { wide, size, half, depth, lift, width, height, cx, cy, bottom, anchors, room }; } type Layout = ReturnType<typeof layout>; export interface BlackBoxFace { /** A unique id. `value` and `onValueChange` use it. */ id: string; /** What this part of your product is called: the label's title and the face's accessible name. */ label: string; /** A line or two on what this part does, or just did. Shown under the label. */ detail?: React.ReactNode; /** A small mark engraved in the middle of the panel. It lights up when the face opens. */ icon?: React.ReactNode; } /** Why the open face changed: autoplay moved on, the visitor looked (hover, keyboard focus), or picked (click, tap, Enter). */ export type BlackBoxReason = "autoplay" | "preview" | "pick"; export interface BlackBoxProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue" | "onChange" | "children"> { /** Up to three faces, in the order autoplay opens them: the top, then the left side, then the right. */ faces: BlackBoxFace[]; /** The open face's id, or `null` for none, when you control it. */ value?: string | null; /** The face that's open at first. None by default. */ defaultValue?: string | null; /** Called whenever a face opens, or the box shuts (`null`), with the reason: autoplay, a preview (hover or keyboard focus) or a pick. */ onValueChange?: (id: string | null, reason: BlackBoxReason) => void; /** 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. */ autoplay?: boolean | number; /** What the box is made of: glossy black, a glass wireframe, or `auto`, black in light mode and glass in dark. */ material?: "auto" | "solid" | "glass"; /** 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. */ labels?: boolean; /** Fly the panels in and shut the box the first time it scrolls into view. */ intro?: boolean; /** How far an open panel lifts off its socket, as a share of the box's edge. */ lift?: number; /** Seconds an opening panel takes to lift (its spring's visual duration). */ liftDuration?: number; /** How much an opening panel overshoots before it settles, from 0 (none) to 1. */ liftBounce?: number; } const subscribeNothing = () => () => {}; /* ── Part refs ─────────────────────────────────────────── */ // Every element a face animates, filled in by callback refs and read only in effects. type Part = "lifter" | "glow" | "rim" | "sheen" | "lit" | "spill" | "dot" | "line" | "label" | "caption"; type Parts = Map<string, HTMLElement>; const key = (side: BlackBoxSide, part: Part) => `${side}:${part}`; // The face ids, joined, stand in for the faces in effects; this finds an id's side among them. const SEP = "\u0000"; const sideIn = (ids: string, id: string | null) => { const index = id === null ? -1 : ids.split(SEP).indexOf(id); return index < 0 ? null : SIDES[index]; }; /* ── BlackBox ──────────────────────────────────────────── */ export function BlackBox({ faces, value, defaultValue = null, onValueChange, autoplay = true, material = "auto", labels = true, intro = true, lift = BOX.lift, liftDuration = TIMING.open.visualDuration, liftBounce = TIMING.open.bounce, className, style, onPointerEnter, onPointerLeave, onFocus, onBlur, ...props }: BlackBoxProps) { const reduced = useReducedMotion() ?? false; const hydrated = React.useSyncExternalStore(subscribeNothing, () => true, () => false); const rootRef = React.useRef<HTMLDivElement>(null); const stageRef = React.useRef<HTMLDivElement>(null); const cubeRef = React.useRef<HTMLDivElement>(null); const coreRef = React.useRef<HTMLDivElement>(null); const buttons = React.useRef(new Map<string, HTMLButtonElement>()); const parts = React.useRef<Parts>(new Map()); const part = React.useCallback( (side: BlackBoxSide, name: Part) => (node: HTMLElement | null) => { if (node) parts.current.set(key(side, name), node); else parts.current.delete(key(side, name)); }, [], ); const shown = faces.slice(0, 3).map((face, i) => ({ ...face, side: SIDES[i] })); const ids = shown.map((face) => face.id).join(SEP); const [width, setWidth] = React.useState(0); const liftShare = clamp(lift, 0, 1); const geo = React.useMemo(() => (width > 0 ? layout(width, liftShare) : null), [width, liftShare]); // Who's asking for which face: a hovering mouse, keyboard focus, a pin (click, tap, Enter) or // autoplay. The first one that has an answer wins. const [hover, setHover] = React.useState<string | null>(null); const [focus, setFocus] = React.useState<string | null>(null); const [pinned, setPinned] = React.useState<string | null>(null); const [auto, setAuto] = React.useState<string | null>(defaultValue); const reason = React.useRef<BlackBoxReason>("autoplay"); const interval = autoplay === true ? TIMING.autoplay : typeof autoplay === "number" ? autoplay : 0; const [stopped, setStopped] = React.useState(false); const running = interval > 0 && !stopped && !reduced && shown.length > 0; const [pointerIn, setPointerIn] = React.useState(false); const [focusIn, setFocusIn] = React.useState(false); const [pageHidden, setPageHidden] = React.useState(false); const inView = useInView(rootRef, { amount: 0.4 }); const paused = pointerIn || focusIn || pageHidden || !inView; const [shut, setShut] = React.useState(false); // the intro has played const ready = !intro || reduced || shut; // Under reduced motion nothing cycles, so the first face rests open where autoplay would have // shown it. Gated on hydration: useReducedMotion is already true in the hydration render. const still = hydrated && reduced && interval > 0 ? (shown[0]?.id ?? null) : null; const internal = hover ?? focus ?? pinned ?? auto ?? still; const requested = value !== undefined ? value : internal; const active = sideIn(ids, requested) ? requested : null; const timers = React.useRef({ intent: 0, leave: 0, resume: 0 }); React.useEffect(() => { const current = timers.current; return () => { window.clearTimeout(current.intent); window.clearTimeout(current.leave); window.clearTimeout(current.resume); }; }, []); // Tell the parent when the face the visitor (or autoplay) asked for changes. const onChange = React.useRef(onValueChange); React.useEffect(() => { onChange.current = onValueChange; }); const reported = React.useRef(internal); React.useEffect(() => { if (internal === reported.current) return; reported.current = internal; onChange.current?.(internal, reason.current); }, [internal]); // Width drives every size; the observer reports once on its own as soon as it's attached. React.useEffect(() => { const root = rootRef.current; if (!root) return; const observer = new ResizeObserver(([entry]) => setWidth(Math.round(entry.contentRect.width))); observer.observe(root); return () => observer.disconnect(); }, []); React.useEffect(() => { const update = () => setPageHidden(document.hidden); document.addEventListener("visibilitychange", update); return () => document.removeEventListener("visibilitychange", update); }, []); /* ── the box shuts ── */ const played = React.useRef(false); React.useEffect(() => { const stage = stageRef.current; if (!geo || !stage || played.current) return; if (!intro || reduced) { played.current = true; stage.style.opacity = "1"; return; } if (!inView) return; played.current = true; stage.style.opacity = "1"; const far = `translateZ(${geo.size * BOX.spread}px)`; const frame = cubeRef.current?.querySelectorAll<HTMLElement>("[data-frame]") ?? []; frame.forEach((node) => animate(node, { opacity: [0, 1] }, TIMING.reveal)); SIDES.forEach((side, i) => { const lifter = parts.current.get(key(side, "lifter")); const glow = parts.current.get(key(side, "glow")); if (lifter) { const delay = i * TIMING.stagger; animate(lifter, { transform: [far, AT_REST] }, { ...TIMING.assemble, delay }); animate(lifter, { opacity: [0, 1] }, { ...TIMING.reveal, delay }); } if (glow) animate(glow, { opacity: [0, 0.9, 0] }, { ...TIMING.flash, delay: TIMING.flashAfter + i * TIMING.flashStagger }); }); const timer = window.setTimeout(() => setShut(true), TIMING.shut); return () => window.clearTimeout(timer); }, [geo, intro, reduced, inView]); /* ── a face opens, another closes ── */ const openRef = React.useRef<string | null>(null); const turns = React.useRef(0); const sizeRef = React.useRef(""); const place = React.useCallback( (side: BlackBoxSide, open: boolean, g: Layout, instant: boolean) => { const get = (name: Part) => parts.current.get(key(side, name)); const lifter = get("lifter"); const quick = instant || reduced; const fade = quick ? TIMING.fade : open ? TIMING.show : TIMING.hide; const to = (node: HTMLElement | undefined, opacity: number, options: object = fade) => { if (node) animate(node, { opacity }, instant ? { duration: 0 } : options); }; if (lifter) { const transform = open && !reduced ? `translateZ(${g.lift}px)` : AT_REST; const opening = { ...TIMING.open, visualDuration: Math.max(0.01, liftDuration), bounce: clamp(liftBounce, 0, 1) }; animate(lifter, { transform }, instant ? { duration: 0 } : open ? opening : TIMING.close); } to(get("glow"), open ? 1 : 0); to(get("rim"), open ? 1 : 0); to(get("lit"), open ? 1 : 0); to(get("spill"), open ? 1 : 0, quick ? TIMING.fade : open ? TIMING.spill : TIMING.hide); const dot = get("dot"); const line = get("line"); const label = get("label"); const caption = get("caption"); if (!open || quick) { [dot, line, label, caption].forEach((node) => { if (!node) return; if (open) node.style.transform = "none"; to(node, open ? 1 : 0); }); return; } const sheen = get("sheen"); if (sheen) animate(sheen, { transform: ["translateX(0%)", "translateX(330%)"] }, TIMING.sheen); if (dot) animate(dot, { opacity: [0, 1], transform: ["scale(0.3)", "scale(1)"] }, { ...TIMING.show, delay: TIMING.lead }); if (line) animate(line, { opacity: [1, 1], transform: ["scaleX(0)", "scaleX(1)"] }, { ...TIMING.draw, delay: TIMING.lead }); const rise = [`translateY(${BOX.rise}px)`, "translateY(0px)"]; if (label) animate(label, { opacity: [0, 1], transform: rise }, { ...TIMING.show, delay: TIMING.lead + TIMING.labelLag }); if (caption) animate(caption, { opacity: [0, 1], transform: rise }, { ...TIMING.show, delay: TIMING.lead }); }, [reduced, liftDuration, liftBounce], ); React.useEffect(() => { if (!geo || !ready) return; // A new size (or lift) re-places whatever is open without animating. const shape = `${geo.size}:${geo.lift}`; const resized = sizeRef.current !== "" && sizeRef.current !== shape; sizeRef.current = shape; const before = openRef.current; if (before === active && !resized) return; openRef.current = active; const was = sideIn(ids, before); const now = sideIn(ids, active); if (resized && was === now) { if (now) place(now, true, geo, true); return; } if (was) place(was, false, geo, resized); if (now) place(now, true, geo, false); // The glass core, a smaller box inside the box, turns a quarter with each face that opens. const core = coreRef.current; if (now && core && !reduced) { turns.current += 1; animate(core, { transform: `rotateY(${turns.current * 90}deg)` }, TIMING.turn); } }, [active, geo, ready, reduced, place, ids]); // A new material swaps under a flash of light through the seams, so the change reads as the // box lighting up rather than a cut. const material0 = React.useRef(material); React.useEffect(() => { if (material0.current === material) return; material0.current = material; if (reduced) return; SIDES.forEach((side, i) => { const glow = parts.current.get(key(side, "glow")); if (glow && sideIn(ids, active) !== side) animate(glow, { opacity: [0.9, 0] }, { ...TIMING.flash, delay: i * TIMING.flashStagger }); }); }, [material, reduced, active, ids]); /* ── autoplay ── */ // Faces in order, then a rest with the box shut. It moves on from whichever face is showing, so // a parent that also sets `value` (from a list beside the box, say) stays in step. The very // first face comes up right after the box has shut, so there's something to read at once. const started = React.useRef(false); React.useEffect(() => { if (!running || paused || !ready) return; const order: (string | null)[] = [...ids.split(SEP), null]; const at = order.indexOf(active); const delay = active === null ? (started.current ? interval * TIMING.rest : TIMING.first) : interval; const timer = window.setTimeout(() => { const next = order[(at + 1) % order.length]; started.current = true; reason.current = "autoplay"; // Reported here rather than from the change, since a controlled box can be asked for the // face its own autoplay already holds, and that wouldn't count as a change. reported.current = next; onChange.current?.(next, "autoplay"); setAuto(next); }, delay); return () => window.clearTimeout(timer); }, [running, paused, ready, active, interval, ids]); /* ── pins close on a tap outside ── */ React.useEffect(() => { if (pinned === null) return; const away = (event: PointerEvent) => { if (rootRef.current?.contains(event.target as Node)) return; reason.current = "pick"; setPinned(null); }; document.addEventListener("pointerdown", away); return () => document.removeEventListener("pointerdown", away); }, [pinned]); /* ── handlers ── */ const pick = (id: string) => { reason.current = "pick"; setStopped(true); setAuto(null); if (pinned === id) { setPinned(null); setHover(null); setFocus(null); } else { setPinned(id); } }; const enterFace = (event: React.PointerEvent, id: string) => { if (event.pointerType !== "mouse") return; window.clearTimeout(timers.current.intent); timers.current.intent = window.setTimeout(() => { reason.current = "preview"; setHover(id); }, TIMING.intent); }; const leaveFace = (event: React.PointerEvent) => { if (event.pointerType === "mouse") window.clearTimeout(timers.current.intent); }; const enterBox = (event: React.PointerEvent<HTMLDivElement>) => { onPointerEnter?.(event); if (event.pointerType !== "mouse") return; window.clearTimeout(timers.current.leave); window.clearTimeout(timers.current.resume); setPointerIn(true); }; const leaveBox = (event: React.PointerEvent<HTMLDivElement>) => { onPointerLeave?.(event); if (event.pointerType !== "mouse") return; window.clearTimeout(timers.current.intent); if (running) { // Autoplay carries on from the face that was left open, so nothing changes as you leave. if (hover !== null) setAuto(hover); setHover(null); timers.current.resume = window.setTimeout(() => setPointerIn(false), TIMING.resume); return; } setPointerIn(false); timers.current.leave = window.setTimeout(() => { reason.current = "preview"; setHover(null); }, TIMING.linger); }; const onKeyDown = (event: React.KeyboardEvent, index: number) => { const list = shown; let next = -1; if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % list.length; else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index - 1 + list.length) % list.length; else if (event.key === "Home") next = 0; else if (event.key === "End") next = list.length - 1; else if (event.key === "Escape") { event.preventDefault(); reason.current = "pick"; setStopped(true); setPinned(null); setFocus(null); setHover(null); setAuto(null); return; } if (next < 0) return; event.preventDefault(); buttons.current.get(list[next].id)?.focus(); }; const tabStop = active ?? shown[0]?.id; const g = geo; return ( <div ref={rootRef} role="group" className={cn("relative isolate w-full @container/black-box", MATERIAL[material], className)} style={{ "--bb-light": "var(--black-box-light, #4f6bff)", ...style } as React.CSSProperties} onPointerEnter={enterBox} onPointerLeave={leaveBox} onFocus={(event) => { onFocus?.(event); setFocusIn(true); }} onBlur={(event) => { onBlur?.(event); if (!event.currentTarget.contains(event.relatedTarget as Node)) setFocusIn(false); }} {...props} > <div ref={stageRef} style={{ opacity: 0 }} className="relative h-[calc(2*clamp(96px,50cqw,240px))] @[36rem]/black-box:h-[calc(2*clamp(96px,30cqw,300px))]" > {g && ( <> {/* Behind the box: its shadow, the glass glow, and the light each open face spills. */} <div aria-hidden className="pointer-events-none absolute inset-0"> <div className="absolute rounded-[50%]" style={{ left: g.cx - g.size * 1.1, top: g.cy - g.size * 0.8, width: g.size * 2.2, height: g.size * 1.9, opacity: "var(--bb-shade)", background: "radial-gradient(closest-side, rgb(0 0 0 / 0.09), transparent)", }} /> <div className="absolute rounded-[50%]" style={{ left: g.cx - g.size * 0.62, top: g.bottom - g.size * 0.09, width: g.size * 1.24, height: g.size * 0.24, opacity: "var(--bb-shade)", background: "radial-gradient(closest-side, rgb(0 0 0 / 0.28), transparent)", }} /> <div className="absolute rounded-[50%]" style={{ left: g.cx - g.size, top: g.cy - g.size, width: g.size * 2, height: g.size * 2, opacity: "var(--bb-ghost)", background: `radial-gradient(closest-side, color-mix(in srgb, ${LIGHT} 16%, transparent), transparent)`, }} /> {shown.map(({ side }) => ( <div key={side} ref={part(side, "spill")} className="absolute rounded-[50%] will-change-[opacity]" style={{ left: g.anchors[side].x - g.size * 0.85, top: g.anchors[side].y - g.size * 0.85, width: g.size * 1.7, height: g.size * 1.7, opacity: 0, background: `radial-gradient(closest-side, color-mix(in srgb, ${LIGHT} 42%, transparent), transparent)`, }} /> ))} </div> {/* The box: a zero-size point at its center, in perspective, holding the posed cube. */} <div className="absolute size-0" style={{ left: g.cx, top: g.cy, perspective: g.depth }} > <div ref={cubeRef} className="absolute size-0 transform-3d" style={{ transform: POSE }}> {HIDDEN.map((placeFace) => ( <div key={placeFace} aria-hidden className="pointer-events-none absolute" style={{ left: -g.half, top: -g.half, width: g.size, height: g.size, opacity: "var(--bb-ghost)", boxShadow: "inset 0 0 0 1px var(--bb-line)", transform: `${placeFace} translateZ(${g.half}px)`, }} /> ))} <div ref={coreRef} aria-hidden className="pointer-events-none absolute size-0 transform-3d will-change-transform" style={{ transform: "rotateY(0deg)", opacity: "var(--bb-ghost)" }} > {CORE.map((placeFace) => ( <div key={placeFace} className="absolute" style={{ left: (-g.size * BOX.core) / 2, top: (-g.size * BOX.core) / 2, width: g.size * BOX.core, height: g.size * BOX.core, transform: `${placeFace} translateZ(${(g.size * BOX.core) / 2}px)`, background: `color-mix(in srgb, ${LIGHT} 14%, transparent)`, boxShadow: `inset 0 0 0 1px ${LIGHT}, 0 0 ${g.size * 0.08}px color-mix(in srgb, ${LIGHT} 45%, transparent)`, }} /> ))} </div> {SIDES.map((side, index) => { const face = shown[index]; const inset = g.size * BOX.inset; const open = face !== undefined && face.id === active; const panelClass = "absolute overflow-hidden rounded-[6%] will-change-transform"; const panelStyle: React.CSSProperties = { left: inset, top: inset, width: g.size - inset * 2, height: g.size - inset * 2, transform: AT_REST, outlineColor: LIGHT, }; const inside = ( <> <span aria-hidden className="absolute inset-0" style={{ background: FACE[side].fill }} /> <span aria-hidden className="absolute inset-0 rounded-[inherit]" style={{ boxShadow: "inset 0 0 0 1px var(--bb-rim), inset 0 1px 0 rgb(255 255 255 / 0.06)" }} /> <span ref={part(side, "rim")} aria-hidden className="absolute inset-0 rounded-[inherit] will-change-[opacity]" style={{ opacity: 0, boxShadow: `inset 0 0 0 1px ${LIGHT}, inset 0 0 ${g.size * 0.14}px -${g.size * 0.03}px ${LIGHT}`, }} /> <span ref={part(side, "sheen")} aria-hidden 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" /> {face?.icon && ( <span aria-hidden className="absolute inset-0 grid place-items-center [&_svg]:size-full" > <span className="relative" style={{ width: g.size * 0.2, height: g.size * 0.2 }}> <span className="absolute inset-0" style={{ color: "var(--bb-icon)" }}> {face.icon} </span> <span ref={part(side, "lit")} className="absolute inset-0 will-change-[opacity]" style={{ color: LIGHT, opacity: 0 }} > {face.icon} </span> </span> </span> )} </> ); return ( <div key={side} className="absolute transform-3d" style={{ left: -g.half, top: -g.half, width: g.size, height: g.size, transform: `${FACE[side].place} translateZ(${g.half}px)`, }} > <div data-frame aria-hidden className="pointer-events-none absolute inset-0" style={{ background: "var(--bb-body)", boxShadow: "inset 0 0 0 1px var(--bb-line)" }} /> <div ref={part(side, "glow")} aria-hidden className="pointer-events-none absolute will-change-[opacity]" style={{ inset: inset * 0.4, opacity: 0, borderRadius: "6%", background: `radial-gradient(closest-side, color-mix(in srgb, ${LIGHT} 72%, white), color-mix(in srgb, ${LIGHT} 85%, transparent))`, }} /> {face ? ( <button ref={(node) => { part(side, "lifter")(node); if (node) buttons.current.set(face.id, node); else buttons.current.delete(face.id); }} type="button" aria-pressed={open} tabIndex={face.id === tabStop ? 0 : -1} className={cn(panelClass, "cursor-pointer outline-offset-[-3px] focus-visible:outline-2")} style={panelStyle} onClick={() => pick(face.id)} onPointerEnter={(event) => enterFace(event, face.id)} onPointerLeave={leaveFace} onFocus={(event) => { if (!event.currentTarget.matches(":focus-visible")) return; reason.current = "preview"; setFocus(face.id); }} onBlur={() => setFocus(null)} onKeyDown={(event) => onKeyDown(event, index)} > {inside} <span className="sr-only"> {face.label} {face.detail ? <>: {face.detail}</> : null} </span> </button> ) : ( <div ref={part(side, "lifter")} aria-hidden className={panelClass} style={panelStyle}> {inside} </div> )} </div> ); })} </div> </div> {/* Leader lines and labels beside the box, from 36rem up. */} {labels && g.wide && ( <div aria-hidden className="pointer-events-none absolute inset-0"> {shown.map(({ id, side, label, detail }) => { const anchor = g.anchors[side]; const [pinX, pinY] = anchor.pin; const end = FACE[side].end; const from = Math.min(pinX, anchor.edge + (end ? -6 : 6)); const to = Math.max(pinX, anchor.edge + (end ? -6 : 6)); return ( <React.Fragment key={id}> <span ref={part(side, "line")} className="absolute h-px will-change-transform" style={{ left: from, top: pinY - 0.5, width: to - from, opacity: 0, transformOrigin: end ? "left center" : "right center", background: end ? `linear-gradient(to right, ${LIGHT}, color-mix(in srgb, ${LIGHT} 35%, transparent))` : `linear-gradient(to left, ${LIGHT}, color-mix(in srgb, ${LIGHT} 35%, transparent))`, }} /> <span ref={part(side, "dot")} className="absolute size-2 rounded-full will-change-transform" style={{ left: pinX - 4, top: pinY - 4, opacity: 0, background: `color-mix(in srgb, ${LIGHT} 70%, white)`, boxShadow: `0 0 0 3px color-mix(in srgb, ${LIGHT} 35%, transparent), 0 0 14px 2px ${LIGHT}`, }} /> <div ref={part(side, "label")} className={cn("absolute will-change-transform", end ? "text-left" : "text-right")} style={{ top: pinY - 9, maxWidth: g.room, opacity: 0, ...(end ? { left: anchor.edge } : { right: g.width - anchor.edge }), }} > <p className="text-[13px] leading-[18px] font-medium tracking-[-0.01em]">{label}</p> {detail ? <p className={cn("mt-1 text-xs leading-[17px] text-pretty", MUTED)}>{detail}</p> : null} </div> </React.Fragment> ); })} </div> )} </> )} </div> {/* Under 36rem the label sits under the box instead, one at a time in the same spot. */} {labels && ( <div aria-hidden className="pointer-events-none grid justify-items-center px-4 text-center @[36rem]/black-box:hidden"> {shown.map(({ id, side, label, detail }) => ( <div key={id} ref={part(side, "caption")} className="col-start-1 row-start-1 max-w-72 will-change-transform" style={{ opacity: 0 }} > <p className="text-sm font-medium tracking-[-0.01em]">{label}</p> {detail ? <p className={cn("mt-1 text-[13px] leading-5 text-pretty", MUTED)}>{detail}</p> : null} </div> ))} </div> )} </div> ); }Update the import paths
The file imports
cnfrom@/lib/utils. Change the path if your project uses a different alias.
Examples
Finished sections built with Black Box. Each one installs whole, with the component included.
Stack
A backend platform's three parts as a list beside the box. Point at a row and its face opens; leave the list and the box walks it again.
Lattice platform
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { BlackBox, type BlackBoxFace } from "@/components/ui/black-box";
// A backend platform's three parts, as a list that opens the box and a box that walks the list.
const LAYERS: (BlackBoxFace & { stat: string })[] = [
{
id: "functions",
label: "Functions",
detail: "TypeScript or Python, deployed on every push, warm in 38 ms.",
stat: "38 ms cold start",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
<path d="M9 7 4 12l5 5M15 7l5 5-5 5" />
</svg>
),
},
{
id: "queues",
label: "Queues",
detail: "Retries, delays and fan-out without a broker to babysit.",
stat: "1.2M jobs a day",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
<path d="M4 7h16M4 12h11M4 17h6" />
</svg>
),
},
{
id: "postgres",
label: "Postgres",
detail: "A branch of your database for every preview, merged with the code.",
stat: "Branch per preview",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
<ellipse cx="12" cy="6" rx="7" ry="2.5" />
<path d="M5 6v6c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5V6M5 12v6c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5v-6" />
</svg>
),
},
];
export function BlackBoxStackDemo() {
const [value, setValue] = React.useState<string | null>(null);
const [holding, setHolding] = React.useState(false);
const [picked, setPicked] = React.useState(false);
return (
<div className="w-full max-w-4xl @container [--black-box-light:#ff5a1f]">
<div className="grid items-center gap-x-10 gap-y-2 @[40rem]:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<div>
<p className="text-xs font-medium tracking-[0.08em] text-[#e2470f] uppercase dark:text-[#ff7a45]">Lattice platform</p>
<h3 className="mt-3 text-3xl font-semibold tracking-[-0.03em] text-balance text-zinc-950 dark:text-white">
Your whole backend, in one box.
</h3>
<ol
className="mt-7 border-t border-zinc-950/10 dark:border-white/10"
onPointerLeave={() => setHolding(false)}
onFocus={() => setHolding(true)}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node)) setHolding(false);
}}
>
{LAYERS.map((layer, i) => {
const on = value === layer.id;
return (
<li key={layer.id} className="border-b border-zinc-950/10 dark:border-white/10">
<button
type="button"
aria-pressed={on}
onPointerEnter={(event) => {
if (event.pointerType !== "mouse") return;
setHolding(true);
setValue(layer.id);
}}
onFocus={() => setValue(layer.id)}
onClick={() => {
setPicked(true);
setValue(layer.id);
}}
className="group relative flex w-full gap-4 py-4 text-left outline-none focus-visible:ring-2 focus-visible:ring-[#ff5a1f] focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-950"
>
<span
aria-hidden
className={cn(
"absolute top-4 bottom-4 -left-px w-0.5 origin-top rounded-full bg-[#ff5a1f] transition-[opacity,transform] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]",
on ? "scale-y-100 opacity-100" : "scale-y-50 opacity-0",
)}
/>
<span className="pl-4 font-mono text-xs leading-6 text-zinc-400 tabular-nums dark:text-zinc-500">
0{i + 1}
</span>
<span className="min-w-0 flex-1">
<span className="flex items-baseline justify-between gap-3">
<span
className={cn(
"text-[15px] leading-6 font-medium tracking-[-0.01em] transition-colors duration-200",
on ? "text-zinc-950 dark:text-white" : "text-zinc-500 dark:text-zinc-400",
)}
>
{layer.label}
</span>
<span className="shrink-0 font-mono text-[11px] text-zinc-400 tabular-nums dark:text-zinc-500">
{layer.stat}
</span>
</span>
<span className="mt-1 block text-[13px] leading-5 text-pretty text-zinc-500 dark:text-zinc-400">
{layer.detail}
</span>
</span>
</button>
</li>
);
})}
</ol>
</div>
<BlackBox
faces={LAYERS}
value={value}
onValueChange={(id) => setValue(id)}
autoplay={!holding && !picked}
labels={false}
aria-label="Lattice's three parts"
/>
</div>
</div>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Black Box: 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. This demo, Stack: A backend platform's three parts as a list beside the box. Point at a row and its face opens; leave the list and the box walks it again. 1. Install it: npx shadcn@latest add @keyframeui/black-box-stack-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/black-box-stack-demo.json instead.) It adds the demo, components/black-box-stack-demo.tsx, and the component, components/ui/black-box.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <BlackBox> on its own. 3. These props set its feel. Change them before you change the file: - material (default "auto"): What the box is made of: glossy black, a glass wireframe, or `auto`, black in light mode and glass in dark. - intro (default true): Fly the panels in and shut the box the first time it scrolls into view. - lift (default 0.15): How far an open panel lifts off its socket, as a share of the box's edge. - liftDuration (default 0.42): How long an opening panel takes to lift, in seconds (its spring's visual duration). - liftBounce (default 0.16): How much an opening panel overshoots before it settles, from 0 (not at all) to 1. 4. Don't change, in components/ui/black-box.tsx: - Panels, lights, lines and labels animate only transform and opacity, through `animate()` on the nodes kept in `parts`, each on its own layer, so nothing repaints while a panel flies. - Requests for a face resolve in one order: hover, then keyboard focus, then a pin, then autoplay. A controlled `value` overrides them all. - A mouse opens a face only after resting on it for the hover intent, and the face stays open while the pointer is anywhere over the box; leaving waits before it closes. - Autoplay holds while the pointer is over the box, focus is inside it, the tab is hidden or the box is under 40% in view, then picks up from the face left open. A click or Esc stops it for good. - Reduced motion has no fly-in, lift, turn or autoplay: every change fades, and the first face rests open once hydrated, since `useReducedMotion` is already true in the hydration render. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/black-box.md
X-ray
A secrets vault for AI agents, with a switch that turns the sealed box to glass so you can see the core inside.
Keel keeps every credential your agents use in one sealed box, and lets you see straight through it.
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { BlackBox, type BlackBoxFace } from "@/components/ui/black-box";
// A secrets vault for AI agents: sealed to them, see-through to you. The switch flips the box
// between solid and glass.
const FACES: BlackBoxFace[] = [
{
id: "seal",
label: "Encrypted",
detail: "AES-256 per workspace, with the keys in a hardware module you rotate yourself.",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
<rect x="5" y="10.5" width="14" height="9.5" rx="2" />
<path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5" />
</svg>
),
},
{
id: "scope",
label: "Scoped",
detail: "An agent gets a token for one tool, for one run. Then it's gone.",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
<circle cx="8.5" cy="12" r="3.5" />
<path d="M12 12h8M17 12v3M20 12v2" />
</svg>
),
},
{
id: "log",
label: "Logged",
detail: "Every read is signed and in your SIEM within a second.",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
<path d="M7 4h7l4 4v12H7z" />
<path d="M14 4v4h4M10 13h5M10 16.5h3" />
</svg>
),
},
];
const MODES = [
{ id: "solid", label: "Sealed" },
{ id: "glass", label: "X-ray" },
] as const;
export function BlackBoxXrayDemo() {
const [mode, setMode] = React.useState<"solid" | "glass">("solid");
return (
<div className="w-full max-w-3xl text-center [--black-box-light:#19d48b]">
<h3 className="text-2xl font-semibold tracking-[-0.03em] text-balance text-zinc-950 dark:text-white">
Shut to your agents. Glass to you.
</h3>
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-pretty text-zinc-500 dark:text-zinc-400">
Keel keeps every credential your agents use in one sealed box, and lets you see straight through it.
</p>
<div
role="group"
aria-label="View"
className="relative mx-auto mt-6 grid w-fit grid-cols-2 rounded-full bg-zinc-950/[0.05] p-1 text-[13px] font-medium ring-1 ring-zinc-950/[0.06] ring-inset dark:bg-white/[0.06] dark:ring-white/10"
>
<span
aria-hidden
className={cn(
"absolute inset-y-1 left-1 w-[calc(50%-4px)] rounded-full bg-white shadow-sm ring-1 ring-zinc-950/[0.06] transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] dark:bg-zinc-700 dark:ring-white/10",
mode === "glass" && "translate-x-full",
)}
/>
{MODES.map((option) => (
<button
key={option.id}
type="button"
aria-pressed={mode === option.id}
onClick={() => setMode(option.id)}
className={cn(
"relative rounded-full px-5 py-1.5 transition-colors duration-200 outline-none focus-visible:ring-2 focus-visible:ring-[#19d48b]",
mode === option.id ? "text-zinc-950 dark:text-white" : "text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200",
)}
>
{option.label}
</button>
))}
</div>
<BlackBox faces={FACES} material={mode} className="mt-2" aria-label="How Keel keeps credentials" />
</div>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Black Box: 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. This demo, X-ray: A secrets vault for AI agents, with a switch that turns the sealed box to glass so you can see the core inside. 1. Install it: npx shadcn@latest add @keyframeui/black-box-xray-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/black-box-xray-demo.json instead.) It adds the demo, components/black-box-xray-demo.tsx, and the component, components/ui/black-box.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <BlackBox> on its own. 3. These props set its feel. Change them before you change the file: - material (default "auto"): What the box is made of: glossy black, a glass wireframe, or `auto`, black in light mode and glass in dark. - intro (default true): Fly the panels in and shut the box the first time it scrolls into view. - lift (default 0.15): How far an open panel lifts off its socket, as a share of the box's edge. - liftDuration (default 0.42): How long an opening panel takes to lift, in seconds (its spring's visual duration). - liftBounce (default 0.16): How much an opening panel overshoots before it settles, from 0 (not at all) to 1. 4. Don't change, in components/ui/black-box.tsx: - Panels, lights, lines and labels animate only transform and opacity, through `animate()` on the nodes kept in `parts`, each on its own layer, so nothing repaints while a panel flies. - Requests for a face resolve in one order: hover, then keyboard focus, then a pin, then autoplay. A controlled `value` overrides them all. - A mouse opens a face only after resting on it for the hover intent, and the face stays open while the pointer is anywhere over the box; leaving waits before it closes. - Autoplay holds while the pointer is over the box, focus is inside it, the tab is hidden or the box is under 40% in view, then picks up from the face left open. A click or Esc stops it for good. - Reduced motion has no fly-in, lift, turn or autoplay: every change fades, and the first face rests open once hydrated, since `useReducedMotion` is already true in the hydration render. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/black-box.md
Props
| Prop | Type | Default | Description |
|---|---|---|---|
faces Required | BlackBoxFace[] | None | Up to three faces, in the order autoplay opens them: the top, then the left side, then the right. Each is { id, label, detail?, icon? }: label titles the label and names the face for screen readers, detail is a line or two under it, and icon is engraved in the middle of the panel and lights up when it opens. |
value | string | null | None | The open face's id, or null for none, when you control it. |
defaultValue | string | null | null | The face that's open at first. None by default. |
onValueChange | (id: string | null, reason: "autoplay" | "preview" | "pick") => void | None | Called whenever a face opens, or the box shuts (null), with the reason: autoplay, a preview (hover or keyboard focus) or a pick (click, tap, Enter). |
autoplay | boolean | number | true | 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. |
material | "auto" | "solid" | "glass" | "auto" | What the box is made of: glossy black, a glass wireframe, or auto, black in light mode and glass in dark. |
labels | boolean | true | 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. |
intro | boolean | true | Fly the panels in and shut the box the first time it scrolls into view. |
lift | number | 0.15 | How far an open panel lifts off its socket, as a share of the box's edge. |
liftDuration | number | 0.42 | How long an opening panel takes to lift, in seconds (its spring's visual duration). |
liftBounce | number | 0.16 | How much an opening panel overshoots before it settles, from 0 (not at all) to 1. |
className | string | None | Extra classes for the root. Set the light's color with --black-box-light (default #4f6bff), e.g. [--black-box-light:#ff5a1f]; glass draws its lines in the text color. |
facesRequiredType
BlackBoxFace[]Up to three faces, in the order autoplay opens them: the top, then the left side, then the right. Each is
{ id, label, detail?, icon? }:labeltitles the label and names the face for screen readers,detailis a line or two under it, andiconis engraved in the middle of the panel and lights up when it opens.valueType
string | nullThe open face's id, or
nullfor none, when you control it.defaultValueType
string | nullDefaultnullThe face that's open at first. None by default.
onValueChangeType
(id: string | null, reason: "autoplay" | "preview" | "pick") => voidCalled whenever a face opens, or the box shuts (
null), with the reason: autoplay, a preview (hover or keyboard focus) or a pick (click, tap, Enter).autoplayType
boolean | numberDefaulttrueOpen 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.
materialType
"auto" | "solid" | "glass"Default"auto"What the box is made of: glossy black, a glass wireframe, or
auto, black in light mode and glass in dark.labelsType
booleanDefaulttrueShow 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.
introType
booleanDefaulttrueFly the panels in and shut the box the first time it scrolls into view.
liftType
numberDefault0.15How far an open panel lifts off its socket, as a share of the box's edge.
liftDurationType
numberDefault0.42How long an opening panel takes to lift, in seconds (its spring's visual duration).
liftBounceType
numberDefault0.16How much an opening panel overshoots before it settles, from 0 (not at all) to 1.
classNameType
stringExtra classes for the root. Set the light's color with
--black-box-light(default#4f6bff), e.g.[--black-box-light:#ff5a1f]; glass draws its lines in the text color.