A how-it-works section with the product as a black box and its steps beside it. The box opens one face per step on its own while a hairline fills under the step that's playing; point at a step and its face opens, point at a face and its step lights up, pick one and it stays.
registry/blocks/black-box-feature.tsx
"use client";
import * as React from "react";
import { animate, useInView } from "motion/react";
import { cn } from "@/lib/utils";
import { BlackBox, type BlackBoxFace } from "@/components/ui/black-box";
/* ── Content ─────────────────────────────────────────────────
* Everything you'd change to make the section yours: the
* palette, the copy and the three steps. The box opens one face
* per step on its own, with a timer line under the step that's
* playing; point at a step (or a face) and the other follows,
* pick one and it stays.
* ─────────────────────────────────────────────────────────── */
const PALETTE = {
light: { paper: "#ffffff", ink: "#0a0a0b", accent: "#3355ff" },
dark: { paper: "#060709", ink: "#f4f5f7", accent: "#7088ff" },
};
const EYEBROW = "How Basalt closes";
const HEADLINE = ["Three passes.", "Every one leaves a trail."];
const SUBLINE =
"Close the month without a spreadsheet in sight, and still open any total down to the receipt behind it.";
/** Time each step stays open while the section plays on its own, in ms. */
const STEP_TIME = 3600;
const STEPS: (BlackBoxFace & { proof: string; body: string })[] = [
{
id: "pull",
label: "Pull",
detail: "Bank, card and payroll feeds",
body: "Read-only feeds from every bank, card program and payroll run, refreshed each night before anyone's up.",
proof: "3 banks · 2 card programs · payroll",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
<path d="M12 4v11M7.5 10.5 12 15l4.5-4.5M5 19h14" />
</svg>
),
},
{
id: "match",
label: "Match",
detail: "Every line to its receipt",
body: "Each transaction is tied to its invoice, receipt or contract. Anything it can't place goes to the person who spent it.",
proof: "2,024 of 2,031 matched on their own",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
<circle cx="9" cy="12" r="5" />
<circle cx="15" cy="12" r="5" />
</svg>
),
},
{
id: "close",
label: "Close",
detail: "Locked, with the working",
body: "The period locks with a close pack your accountant can read: every adjustment, who approved it and why.",
proof: "Locked Oct 3, 06:12 · 41-page pack",
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>
),
},
];
/* ── Section ─────────────────────────────────────────────── */
export function BlackBoxFeature() {
const [value, setValue] = React.useState<string | null>(null);
const [holding, setHolding] = React.useState(false);
const [picked, setPicked] = React.useState(false);
const [overBox, setOverBox] = React.useState(false); // the box holds its own autoplay; this just hides the timer
const playing = !holding && !picked;
return (
<section
style={
{
"--paper-light": PALETTE.light.paper,
"--ink-light": PALETTE.light.ink,
"--accent-light": PALETTE.light.accent,
"--paper-dark": PALETTE.dark.paper,
"--ink-dark": PALETTE.dark.ink,
"--accent-dark": PALETTE.dark.accent,
} as React.CSSProperties
}
className="bg-(--paper) text-(--ink) [--accent:var(--accent-light)] [--ink:var(--ink-light)] [--paper:var(--paper-light)] dark:[--accent:var(--accent-dark)] dark:[--ink:var(--ink-dark)] dark:[--paper:var(--paper-dark)]"
>
<div className="mx-auto max-w-6xl px-6 py-24 sm:py-32">
<div className="max-w-2xl">
<p className="text-[13px] font-medium text-(--accent)">{EYEBROW}</p>
<h2 className="mt-3 text-4xl leading-[1.05] font-medium tracking-[-0.04em] sm:text-5xl">
<span className="block">{HEADLINE[0]}</span>
<span className="block text-zinc-400 dark:text-zinc-500">{HEADLINE[1]}</span>
</h2>
<p className="mt-5 max-w-xl text-base leading-relaxed text-pretty text-zinc-600 sm:text-[17px] dark:text-zinc-400">
{SUBLINE}
</p>
</div>
<div className="mt-14 grid items-center gap-x-16 gap-y-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<BlackBox
faces={STEPS}
value={value}
onValueChange={(id, reason) => {
setValue(id);
if (reason === "pick") setPicked(true);
}}
autoplay={playing ? STEP_TIME : false}
labels={false}
onPointerEnter={(event) => {
if (event.pointerType === "mouse") setOverBox(true);
}}
onPointerLeave={() => setOverBox(false)}
className="mx-auto max-w-[34rem] [--black-box-light:var(--accent)]"
aria-label="Basalt's three passes"
/>
<ol
className="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);
}}
>
{STEPS.map((step, i) => {
const on = value === step.id;
return (
<li key={step.id} className="relative 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(step.id);
}}
onFocus={() => setValue(step.id)}
onClick={() => {
setPicked(true);
setValue(step.id);
}}
className="grid w-full grid-cols-[2.5rem_minmax(0,1fr)] py-6 text-left outline-none focus-visible:ring-2 focus-visible:ring-(--accent) focus-visible:ring-offset-4 focus-visible:ring-offset-(--paper)"
>
<span
className={cn(
"font-mono text-xs leading-7 tabular-nums transition-colors duration-200",
on ? "text-(--accent)" : "text-zinc-400 dark:text-zinc-500",
)}
>
0{i + 1}
</span>
<span>
<span
className={cn(
"block text-xl leading-7 font-medium tracking-[-0.02em] transition-colors duration-200",
on ? "text-(--ink)" : "text-zinc-400 dark:text-zinc-500",
)}
>
{step.label}
</span>
<span
className={cn(
"mt-2 block text-[15px] leading-6 text-pretty transition-colors duration-200",
on ? "text-zinc-600 dark:text-zinc-300" : "text-zinc-400 dark:text-zinc-500",
)}
>
{step.body}
</span>
<span className="mt-3 block font-mono text-xs text-zinc-500 dark:text-zinc-400">{step.proof}</span>
</span>
</button>
{on && playing && !overBox ? <TimerLine key={step.id} duration={STEP_TIME} /> : null}
</li>
);
})}
</ol>
</div>
</div>
</section>
);
}
/* ── TimerLine ─────────────────────────────────────────────── */
// A hairline under the step that's playing, filling over the step's time. It starts again
// whenever it comes back into view, as the box's own timer does.
function TimerLine({ duration }: { duration: number }) {
const ref = React.useRef<HTMLSpanElement>(null);
const inView = useInView(ref);
React.useEffect(() => {
const line = ref.current;
if (!line || !inView) return;
const run = animate(line, { transform: ["scaleX(0)", "scaleX(1)"] }, { duration: duration / 1000, ease: "linear" });
return () => run.stop();
}, [inView, duration]);
return (
<span
ref={ref}
aria-hidden
style={{ transform: "scaleX(0)" }}
className="absolute inset-x-0 -bottom-px h-px origin-left bg-(--accent) will-change-transform motion-reduce:hidden"
/>
);
}
Live preview and the components it's built with: https://keyframeui.com/blocks/feature-sections/black-box-feature