# Black Box Feature

> 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.

A shadcn/ui block from the [@keyframeui](https://keyframeui.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/keyframeui/black-box-feature).

- Page, with a live preview: https://shadcn.exchange/r/keyframeui/black-box-feature
- Screenshot: https://shadcn.exchange/shot/keyframeui/black-box-feature/light.png
- Registry item: https://keyframeui.com/r/black-box-feature.json
- Categories: blocks, feature-sections
- Listed: 2026-11-02, source last changed 2026-11-02

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/keyframeui/black-box-feature.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `motion`
- Registry items: `https://keyframeui.com/r/black-box.json`, `utils`

## Notes from the registry

Live preview and the components it's built with: https://keyframeui.com/blocks/feature-sections/black-box-feature

## Files

### `registry/blocks/black-box-feature.tsx`

```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"
    />
  );
}
```
