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.
registry/blocks/black-box-hero.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'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>
);
}
Live preview and the components it's built with: https://keyframeui.com/blocks/hero-sections/black-box-hero