# Black Box Hero

> A hero for an AI product that shows its work. A question drops down a wire into a glossy black box, its three faces open in turn with what each part did for that question, and the answer comes back up beside it. Point at a face to hold it open. Under it, four numbers in oversized type roll into place.

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

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

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/keyframeui/black-box-hero.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`, `https://keyframeui.com/r/odometer.json`, `utils`

## Notes from the registry

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

## Files

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

```tsx
"use client";

import * as React from "react";
import { animate, useReducedMotion } from "motion/react";

import { cn } from "@/lib/utils";
import { BlackBox, type BlackBoxFace } from "@/components/ui/black-box";
import { Odometer } from "@/components/ui/odometer";

/* ── Content ─────────────────────────────────────────────────
 * Everything you'd change to make the hero yours: the palette,
 * the nav, the copy, the questions the box answers and the
 * numbers under it. Each question drops into the box, its three
 * faces open in turn with what that part did for this question,
 * and the answer comes back out. Point at a face to hold it open;
 * click one and the questions stop on their answer.
 * ─────────────────────────────────────────────────────────── */

/**
 * Crisp paper and ink, and one electric blue: the light inside the box,
 * the agent's avatar and the working dot. Dark mode turns the box to glass.
 */
const PALETTE = {
  light: { paper: "#fbfbfc", ink: "#0a0a0b", accent: "#3355ff" },
  dark: { paper: "#060709", ink: "#f4f5f7", accent: "#7088ff" },
};

const BRAND = { name: "Basalt", handle: "@basalt", href: "#" };
const NAV_LINKS = [
  { label: "Product", href: "#product" },
  { label: "Customers", href: "#customers" },
  { label: "Pricing", href: "#pricing" },
  { label: "Docs", href: "#docs" },
];
const NAV_ACTIONS = {
  demo: { label: "Book a demo", href: "#demo" },
  signIn: { label: "Sign in", href: "#sign-in" },
  signUp: { label: "Start free", href: "#start" },
};

const HEADLINE = ["Books that close", "themselves."];
const SUBLINE =
  "Basalt reconciles every account overnight. Ask it anything about your numbers and it opens up to show where each answer came from.";
const PRIMARY_CTA = { label: "Start closing", href: "#start" };
const SECONDARY_CTA = { label: "Talk to an accountant", href: "#talk" };

/** The person asking, shown as initials beside each question. */
const ASKER = "MO";

/** The three parts of the box, in the order they work: top, left, right. */
const PARTS = [
  {
    id: "pull",
    label: "Pull",
    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",
    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: "explain",
    label: "Explain",
    icon: (
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
        <path d="M5 6h14M5 11h14M5 16h8" />
      </svg>
    ),
  },
] as const;

type PartId = (typeof PARTS)[number]["id"];

/** Each question, what every part of the box did for it, and the answer that comes back. */
const QUESTIONS: { ask: string; steps: Record<PartId, string>; answer: React.ReactNode }[] = [
  {
    ask: "Why is software spend up 22% in October?",
    steps: {
      pull: "1,284 October transactions, and September's to compare.",
      match: "41 charges tied to invoices. Two are annual renewals.",
      explain: "Without the renewals, software is down 3%.",
    },
    answer: (
      <>
        Two annual renewals landed in October: Northwind CRM (<b>$14,400</b>) and Tinta (<b>$6,100</b>). Without them,
        software is down 3%.
      </>
    ),
  },
  {
    ask: "Are we still on track for 18 months of runway?",
    steps: {
      pull: "Cash in 3 accounts, 6 payroll runs, every contract.",
      match: "Forecast within 2% of the last 90 days of actuals.",
      explain: "Two March hires raised burn; runway was 19 months.",
    },
    answer: (
      <>
        Yes, just: <b>18.4 months</b>, down from 19 after the two March hires. A third hire takes it to 16.9.
      </>
    ),
  },
  {
    ask: "What's still blocking the September close?",
    steps: {
      pull: "14 accounts, 2,031 September transactions.",
      match: "2,024 matched on their own. 7 have no receipt.",
      explain: "The 7 belong to three people. Each has been asked.",
    },
    answer: (
      <>
        <b>Seven card charges</b> without receipts, from three people. I&apos;ve messaged each of them; the books close
        when they reply.
      </>
    ),
  },
];

/** The numbers under the hero. `value` rolls in when the strip scrolls into view. */
const STATS: { value: number; format?: Intl.NumberFormatOptions; suffix?: string; caption: string }[] = [
  { value: 2400000, format: { notation: "compact", maximumFractionDigits: 1 }, caption: "transactions matched last month" },
  { value: 4, suffix: " hrs", caption: "to close the month, down from six days" },
  { value: 0.997, format: { style: "percent", maximumFractionDigits: 1 }, caption: "of transactions matched without a person" },
  { value: 1900, suffix: "+", caption: "finance teams closing on Basalt" },
];

/** How long each beat of a question plays, in ms: asked, the three parts at work, answered, cleared. */
const BEATS = [900, 2600, 2600, 2600, 5200, 450];
const ANSWERED = 4;

/* ── Section ─────────────────────────────────────────────── */

export function BlackBoxHero() {
  const reduced = useReducedMotion();
  const [question, setQuestion] = React.useState(0);
  const [beat, setBeat] = React.useState(0);
  const [visitor, setVisitor] = React.useState<string | null>(null);
  const [picked, setPicked] = React.useState(false);
  const pulseRef = React.useRef<HTMLSpanElement>(null);

  // The questions play on their own until the visitor picks a face; pointing at one holds the
  // current beat. Under reduced motion the first question rests on its answer.
  React.useEffect(() => {
    if (reduced) {
      const timer = window.setTimeout(() => setBeat(ANSWERED), 0);
      return () => window.clearTimeout(timer);
    }
    if (picked || visitor !== null) return;
    const timer = window.setTimeout(() => {
      if (beat + 1 < BEATS.length) setBeat(beat + 1);
      else {
        setBeat(0);
        setQuestion((question + 1) % QUESTIONS.length);
      }
    }, BEATS[beat]);
    return () => window.clearTimeout(timer);
  }, [beat, question, visitor, picked, reduced]);

  // A pulse runs down the wire into the box as a question is asked, and back up with the answer.
  React.useEffect(() => {
    const pulse = pulseRef.current;
    if (!pulse || reduced || (beat !== 0 && beat !== ANSWERED)) return;
    const down = beat === 0;
    const run = animate(
      pulse,
      { transform: down ? ["translateY(-100%)", "translateY(300%)"] : ["translateY(300%)", "translateY(-100%)"], opacity: [1, 1] },
      { duration: 0.55, ease: [0.65, 0, 0.35, 1], delay: down ? 0.25 : 0 },
    );
    return () => run.stop();
  }, [beat, question, reduced]);

  const current = QUESTIONS[question];
  const faces: BlackBoxFace[] = PARTS.map((part) => ({ ...part, detail: current.steps[part.id] }));
  const working = beat >= 1 && beat <= 3;
  const value = visitor ?? (working ? PARTS[beat - 1].id : null);

  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="relative overflow-hidden 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)]"
    >
      <header className="mx-auto flex h-14 max-w-6xl items-center gap-8 px-6">
        <a
          href={BRAND.href}
          className="flex items-center gap-2 rounded-md text-[15px] font-semibold tracking-[-0.02em] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-(--accent)"
        >
          <BasaltMark className="size-5" />
          {BRAND.name}
        </a>
        <nav aria-label="Main" className="hidden items-center gap-6 text-[13px] text-zinc-600 md:flex dark:text-zinc-400">
          {NAV_LINKS.map((link) => (
            <a
              key={link.label}
              href={link.href}
              className="rounded-sm transition-colors duration-150 hover:text-(--ink) focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-(--accent)"
            >
              {link.label}
            </a>
          ))}
        </nav>
        <div className="ml-auto flex items-center gap-2 text-[13px] font-medium">
          <a
            href={NAV_ACTIONS.demo.href}
            className="hidden rounded-full px-3.5 py-1.5 ring-1 ring-zinc-950/10 transition-colors duration-150 hover:bg-zinc-950/[0.04] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--accent) sm:block dark:ring-white/15 dark:hover:bg-white/[0.06]"
          >
            {NAV_ACTIONS.demo.label}
          </a>
          <a
            href={NAV_ACTIONS.signIn.href}
            className="rounded-full px-3 py-1.5 text-zinc-700 transition-colors duration-150 hover:text-(--ink) focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--accent) dark:text-zinc-300"
          >
            {NAV_ACTIONS.signIn.label}
          </a>
          <a
            href={NAV_ACTIONS.signUp.href}
            className="rounded-full bg-(--ink) px-3.5 py-1.5 text-(--paper) transition-opacity duration-150 hover:opacity-85 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--accent)"
          >
            {NAV_ACTIONS.signUp.label}
          </a>
        </div>
      </header>

      <div className="mx-auto grid max-w-6xl items-center gap-x-10 gap-y-12 px-6 pt-14 pb-6 sm:pt-20 lg:grid-cols-[minmax(0,44fr)_minmax(0,56fr)] lg:pt-16">
        <div>
          <h1 className="text-[2.6rem] leading-[1.02] font-medium tracking-[-0.045em] sm:text-6xl lg:text-[3.6rem]">
            <span className="block">{HEADLINE[0]}</span>
            <span className="block">{HEADLINE[1]}</span>
          </h1>
          <p className="mt-6 max-w-md text-base leading-relaxed text-pretty text-zinc-600 sm:text-[17px] dark:text-zinc-400">
            {SUBLINE}
          </p>
          <div className="mt-8 flex flex-wrap items-center gap-3 text-sm font-medium">
            <a
              href={PRIMARY_CTA.href}
              className="rounded-full bg-(--ink) px-5 py-2.5 text-(--paper) transition-opacity duration-150 hover:opacity-85 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--accent)"
            >
              {PRIMARY_CTA.label}
            </a>
            <a
              href={SECONDARY_CTA.href}
              className="rounded-full bg-white px-5 py-2.5 ring-1 ring-zinc-950/10 transition-colors duration-150 hover:bg-zinc-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--accent) dark:bg-white/[0.04] dark:ring-white/15 dark:hover:bg-white/[0.08]"
            >
              {SECONDARY_CTA.label}
            </a>
          </div>
        </div>

        <div className="relative min-w-0">
          {/* The thread: every question and answer share one cell, so the tallest holds the space. */}
          <div className="mx-auto grid max-w-[27rem] text-[13px] leading-5">
            {QUESTIONS.map((item, i) => {
              const asked = i === question && beat < BEATS.length - 1;
              const answered = asked && beat >= ANSWERED;
              return (
                <div key={item.ask} aria-hidden={i !== question} className="col-start-1 row-start-1 flex flex-col gap-2.5">
                  <div
                    className={cn(
                      "flex origin-bottom-right items-start gap-2.5 self-end rounded-2xl rounded-br-md bg-white py-2.5 pr-3.5 pl-2.5 shadow-sm ring-1 ring-zinc-950/[0.07] transition-[opacity,transform] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-opacity dark:bg-zinc-900 dark:ring-white/10",
                      asked ? "translate-y-0 scale-100 opacity-100 duration-300" : "translate-y-1.5 scale-[0.98] opacity-0 duration-200 motion-reduce:translate-y-0 motion-reduce:scale-100",
                    )}
                  >
                    <span className="grid size-5 shrink-0 place-items-center rounded-full bg-zinc-200 text-[9px] font-semibold text-zinc-700 dark:bg-zinc-700 dark:text-zinc-200">
                      {ASKER}
                    </span>
                    <p className="text-zinc-700 dark:text-zinc-300">
                      <b className="font-semibold text-(--ink)">{BRAND.handle}</b> {item.ask}
                    </p>
                  </div>
                  <div className="grid">
                    {/* Dots where the answer will land while the box works. */}
                    <div
                      className={cn(
                        "col-start-1 row-start-1 flex origin-top-left items-center gap-2.5 self-start justify-self-start rounded-2xl rounded-tl-md bg-white py-2.5 pr-3.5 pl-2.5 shadow-sm ring-1 ring-zinc-950/[0.07] transition-[opacity,transform] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-opacity dark:bg-zinc-900 dark:ring-white/10",
                        asked && !answered && beat >= 1 ? "scale-100 opacity-100 duration-300" : "scale-95 opacity-0 duration-150 motion-reduce:scale-100",
                      )}
                    >
                      <AgentAvatar />
                      <span className="flex h-5 items-center gap-1 pr-0.5">
                        {[0, 1, 2].map((dot) => (
                          <span
                            key={dot}
                            className="size-1.5 animate-pulse rounded-full bg-zinc-400 motion-reduce:animate-none dark:bg-zinc-500"
                            style={{ animationDelay: `${dot * 160}ms` }}
                          />
                        ))}
                      </span>
                    </div>
                    <div
                      className={cn(
                        "col-start-1 row-start-1 flex origin-top-left items-start gap-2.5 self-start justify-self-start rounded-2xl rounded-tl-md bg-white py-2.5 pr-3.5 pl-2.5 shadow-sm ring-1 ring-zinc-950/[0.07] transition-[opacity,transform] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-opacity dark:bg-zinc-900 dark:ring-white/10",
                        answered ? "translate-y-0 scale-100 opacity-100 delay-100 duration-300" : "-translate-y-1 scale-[0.98] opacity-0 duration-200 motion-reduce:translate-y-0 motion-reduce:scale-100",
                      )}
                    >
                      <AgentAvatar />
                      <p className="text-zinc-700 dark:text-zinc-300 [&_b]:font-semibold [&_b]:text-(--ink)">{item.answer}</p>
                    </div>
                  </div>
                </div>
              );
            })}
          </div>

          {/* The wire between the thread and the box, with the pulse that travels it. */}
          <div aria-hidden className="relative mx-auto mt-2 h-16 w-px overflow-hidden bg-linear-to-b from-zinc-950/5 to-zinc-950/20 dark:from-white/5 dark:to-white/25">
            <span
              ref={pulseRef}
              className="absolute inset-x-0 top-0 h-1/3 bg-linear-to-b from-transparent via-(--accent) to-transparent opacity-0 will-change-transform"
            />
          </div>

          <BlackBox
            faces={faces}
            value={value}
            autoplay={false}
            onValueChange={(id, reason) => {
              if (reason === "autoplay") return;
              setVisitor(id);
              if (reason === "pick") {
                setPicked(true);
                setBeat(ANSWERED);
              }
            }}
            className="-mt-9 [--black-box-light:var(--accent)]"
            aria-label={`How ${BRAND.name} answers`}
          />
        </div>
      </div>

      <div className="mx-auto max-w-6xl px-6 pt-10 pb-24 sm:pb-28">
        <dl className="grid grid-cols-2 gap-x-8 gap-y-12 lg:grid-cols-4">
          {STATS.map((stat) => (
            <div key={stat.caption} className="flex flex-col-reverse justify-end">
              <dt className="mt-3 max-w-[15rem] text-[13px] leading-5 text-pretty text-zinc-500 dark:text-zinc-400">
                {stat.caption}
              </dt>
              <dd className="text-5xl leading-none font-normal tracking-[-0.045em] tabular-nums sm:text-6xl">
                <Odometer value={stat.value} format={stat.format} rollOnView />
                {stat.suffix ? <span>{stat.suffix}</span> : null}
              </dd>
            </div>
          ))}
        </dl>
      </div>
    </section>
  );
}

/* ── Marks ─────────────────────────────────────────────────── */

function AgentAvatar() {
  return (
    <span className="grid size-5 shrink-0 place-items-center rounded-full bg-(--accent) text-white">
      <BasaltMark className="size-3" cut="var(--accent)" />
    </span>
  );
}


// A basalt column seen end-on: a hexagon with the three edges of a cube's near corner.
function BasaltMark({ className, cut = "var(--paper)" }: { className?: string; cut?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden className={className}>
      <path d="M12 2.5 20.5 7.25v9.5L12 21.5l-8.5-4.75v-9.5z" fill="currentColor" />
      <path d="M12 12v9.5M12 12l8.5-4.75M12 12 3.5 7.25" stroke={cut} strokeWidth={1.4} strokeLinejoin="round" />
    </svg>
  );
}
```
