# Blueprint

> A finished layout that builds itself. It starts as a wireframe measured from your real markup, then fills in block by block: text streams in, photos come into focus, buttons pop up. Scrub back and it comes apart again.

An animated React component from KeyframeUI, installed as source with the shadcn CLI. Built with React, Tailwind CSS and Motion.

- Docs and live preview: https://keyframeui.com/components/blueprint
- Tags: Layout, Scroll, Motion
- npm dependencies: motion, clsx, tailwind-merge

## Installation

Register the @keyframeui registry in `components.json` once per project:

```json
{
  "registries": {
    "@keyframeui": "https://keyframeui.com/r/{name}.json"
  }
}
```

Then add the component. It lands in `components/ui/blueprint.tsx`, and the CLI also adds `lib/utils.ts` (the `cn` helper) and installs the npm dependencies:

```bash
npx shadcn@latest add @keyframeui/blueprint
```

Or install by URL, without registering the namespace:

```bash
npx shadcn@latest add https://keyframeui.com/r/blueprint.json
```

## Usage

The main demo shows typical usage.

### Site Builder

An AI site builder's hero. A prompt types itself into the field, Enter goes down, and the field docks in the corner as the page builds beneath it. Each loop is a new prompt with different copy, colors and photos in the same layout, swapped in through `onLoop`, so the same wireframe turns into a different site. Hover to pause it.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/blueprint-demo
```

```tsx title="components/blueprint-demo.tsx"
"use client";

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

import { cn } from "@/lib/utils";
import { Blueprint } from "@/components/ui/blueprint";

type Site = {
  prompt: string;
  domain: string;
  brand: string;
  nav: string[];
  eyebrow: string;
  title: string;
  text: string;
  primary: string;
  secondary: string;
  photo: string;
  alt: string;
  note: string;
  /** [light, dark] */
  paper: [string, string];
  ink: [string, string];
  accent: [string, string];
};

const sites: Site[] = [
  {
    prompt: "A site for a ceramics studio in Lisbon",
    domain: "barro.studio",
    brand: "Barro",
    nav: ["Shop", "Classes", "Visit"],
    eyebrow: "Ceramics studio · Lisbon",
    title: "Pottery for slow mornings",
    text: "Hand-thrown in Alfama, fired twice, and made to be used every day, not saved for guests.",
    primary: "Visit the studio",
    secondary: "Shop bowls",
    photo: "1610701596061-2ecf227e85b2",
    alt: "Stoneware bowls and cups on a wooden table",
    note: "Wheel classes every Saturday",
    paper: ["#f6f1ea", "#1c1714"],
    ink: ["#2b211c", "#f3ebe3"],
    accent: ["#b4532a", "#e0875c"],
  },
  {
    prompt: "A site for an architecture studio in Oslo",
    domain: "fjell.no",
    brand: "Fjell",
    nav: ["Projects", "Studio", "Contact"],
    eyebrow: "Architecture · Oslo",
    title: "Quiet houses for long winters",
    text: "Timber homes that hold the heat, frame the low light, and settle into the land around them.",
    primary: "See the houses",
    secondary: "Our approach",
    photo: "1600585154340-be6161a56a0c",
    alt: "A timber and glass house lit up at dusk",
    note: "Twelve houses since 2014",
    paper: ["#eef1ee", "#121816"],
    ink: ["#18221f", "#e6ece9"],
    accent: ["#2f5d50", "#7fb8a5"],
  },
  {
    prompt: "A landing page for a wine bar in Copenhagen",
    domain: "lillevin.dk",
    brand: "Lille Vin",
    nav: ["Wines", "Kitchen", "Book"],
    eyebrow: "Natural wine · Copenhagen",
    title: "Small growers, long tables",
    text: "Sixty bottles from people who farm by hand, poured by the glass with plates to share.",
    primary: "Book a table",
    secondary: "This week's list",
    photo: "1510812431401-41d2bd2722f3",
    alt: "Friends raising glasses of red wine",
    note: "Open late, Wednesday to Sunday",
    paper: ["#f7efef", "#1d1214"],
    ink: ["#2a1418", "#f3e6e8"],
    accent: ["#7a1f3d", "#e07a9a"],
  },
];

const faces = ["1438761681033-6461ffad8d80", "1500648767791-00dcc994a43e", "1534528741775-53994a69daeb"];

const photo = (id: string, w: number, h: number) =>
  `https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=${w}&h=${h}&q=80`;

// The prompt types while the loop rests on the wireframe, so the rest is long enough to hold it.
const TIMING = { build: 5, hold: 2.5, unbuild: 1, rest: 3.4 };
const TYPE = { delay: 0.45, perChar: 0.032, pause: 0.3, press: 0.22 }; // s
const DOCK = { type: "spring", visualDuration: 0.45, bounce: 0.12 } as const;

type Phase = "typing" | "sent" | "docked";

export function BlueprintDemo() {
  const reduced = useReducedMotion();
  const [loop, setLoop] = React.useState(0);
  const [site, setSite] = React.useState(0);
  const [phase, setPhase] = React.useState<Phase>("typing");
  const prompt = sites[loop % sites.length].prompt;

  React.useEffect(() => {
    for (const s of sites) {
      const img = new Image();
      img.src = photo(s.photo, 640, 800);
    }
  }, []);

  // Each loop: the next prompt types in, Enter goes down, the page swaps to the new site's copy
  // (the wireframe re-measures under it) and the field docks in the corner before the build.
  React.useEffect(() => {
    const at = (s: number, run: () => void) => window.setTimeout(run, s * 1000);
    const typed = TYPE.delay + prompt.length * TYPE.perChar + TYPE.pause;
    const timers = reduced
      ? [at(0, () => setPhase("docked"))]
      : [
          at(0, () => setPhase("typing")),
          at(typed, () => setPhase("sent")),
          at(typed + TYPE.press, () => {
            setSite(loop % sites.length);
            setPhase("docked");
          }),
        ];
    return () => timers.forEach((id) => window.clearTimeout(id));
  }, [loop, prompt, reduced]);

  const current = sites[site];

  return (
    <div className="w-full max-w-3xl overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-xl shadow-zinc-900/5 dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-black/30">
      <div className="flex items-center gap-3 border-b border-zinc-200 px-4 py-2.5 dark:border-zinc-800">
        <div className="flex gap-1.5">
          <span className="size-2.5 rounded-full bg-zinc-200 dark:bg-zinc-700" />
          <span className="size-2.5 rounded-full bg-zinc-200 dark:bg-zinc-700" />
          <span className="size-2.5 rounded-full bg-zinc-200 dark:bg-zinc-700" />
        </div>
        <div className="mx-auto flex min-w-0 items-center gap-1.5 rounded-md bg-zinc-100 px-3 py-1 font-mono text-[11px] text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
          <svg viewBox="0 0 16 16" fill="currentColor" className="size-3 shrink-0" aria-hidden>
            <path d="M5 7V5a3 3 0 1 1 6 0v2h.5A1.5 1.5 0 0 1 13 8.5v4a1.5 1.5 0 0 1-1.5 1.5h-7A1.5 1.5 0 0 1 3 12.5v-4A1.5 1.5 0 0 1 4.5 7H5Zm1.5 0h3V5a1.5 1.5 0 0 0-3 0v2Z" />
          </svg>
          <span className="truncate">{current.domain}</span>
        </div>
        <span className="w-10" />
      </div>

      <div className="relative">
        <Blueprint play="auto" duration={TIMING} onLoop={setLoop}>
          <SitePage site={current} />
        </Blueprint>
        <PromptBar text={prompt} phase={phase} />
      </div>
    </div>
  );
}

function SitePage({ site }: { site: Site }) {
  const palette = {
    "--paper": site.paper[0],
    "--paper-dark": site.paper[1],
    "--ink": site.ink[0],
    "--ink-dark": site.ink[1],
    "--accent": site.accent[0],
    "--accent-dark": site.accent[1],
  } as React.CSSProperties;

  return (
    <div
      data-blueprint="box"
      data-blueprint-label="Page"
      style={palette}
      className="flex flex-col bg-[var(--paper)] px-6 pt-5 pb-8 text-[var(--ink)] sm:min-h-[27rem] sm:px-8 dark:bg-[var(--paper-dark)] dark:text-[var(--ink-dark)]"
    >
      <header className="flex items-center justify-between gap-4">
        <span data-blueprint className="text-sm font-semibold tracking-tight">
          {site.brand}
        </span>
        <nav data-blueprint="text" className="flex items-center gap-6 text-xs opacity-75">
          {site.nav.map((item) => (
            <span key={item}>{item}</span>
          ))}
        </nav>
      </header>

      <div className="mt-8 grid flex-1 items-center gap-8 sm:grid-cols-[1.15fr_1fr]">
        <div>
          <p
            data-blueprint
            className="text-[11px] font-medium tracking-[0.16em] text-[var(--accent)] uppercase dark:text-[var(--accent-dark)]"
          >
            {site.eyebrow}
          </p>
          <h1 data-blueprint className="mt-3 text-3xl/[1.08] font-semibold tracking-tight text-balance sm:text-[2.5rem]/[1.05]">
            {site.title}
          </h1>
          <p data-blueprint className="mt-4 max-w-sm text-sm/6 text-pretty opacity-75">
            {site.text}
          </p>
          <div className="mt-6 flex flex-wrap gap-2.5">
            <a
              data-blueprint
              href="#visit"
              className="inline-flex rounded-full bg-[var(--accent)] px-4 py-2 text-sm font-medium text-[var(--paper)] dark:bg-[var(--accent-dark)] dark:text-[var(--paper-dark)]"
            >
              {site.primary}
            </a>
            <a
              data-blueprint
              href="#shop"
              className="inline-flex rounded-full border border-current/20 px-4 py-2 text-sm font-medium"
            >
              {site.secondary}
            </a>
          </div>
          <div className="mt-8 flex items-center gap-3">
            <div className="flex -space-x-2">
              {faces.map((face) => (
                <img
                  key={face}
                  data-blueprint
                  src={photo(face, 96, 96)}
                  alt=""
                  className="size-7 rounded-full object-cover ring-2 ring-[var(--paper)] dark:ring-[var(--paper-dark)]"
                />
              ))}
            </div>
            <p data-blueprint className="text-xs opacity-70">
              {site.note}
            </p>
          </div>
        </div>
        <img
          data-blueprint
          src={photo(site.photo, 640, 800)}
          alt={site.alt}
          className="aspect-[4/3] w-full rounded-2xl object-cover sm:aspect-[4/5]"
        />
      </div>
    </div>
  );
}

function PromptBar({ text, phase }: { text: string; phase: Phase }) {
  const count = useMotionValue(0);
  const typed = useTransform(count, (c): string => text.slice(0, Math.round(c)));
  const placeholder = useTransform(count, (c): string => (c < 0.5 ? "Describe a site…" : ""));

  React.useEffect(() => {
    if (phase !== "typing") {
      count.jump(text.length);
      return;
    }
    count.jump(0);
    const controls = animate(count, text.length, {
      delay: TYPE.delay,
      duration: text.length * TYPE.perChar,
      ease: "linear",
    });
    return () => controls.stop();
  }, [phase, text, count]);

  const docked = phase === "docked";

  return (
    <div
      className={cn(
        "pointer-events-none absolute inset-0 z-[60] flex p-4 sm:p-5",
        docked ? "items-end justify-start" : "items-center justify-center",
      )}
    >
      <motion.div
        layout
        transition={DOCK}
        style={{ borderRadius: docked ? 999 : 16 }}
        className={cn(
          "flex items-center gap-2.5 border border-zinc-900/10 bg-white/85 text-zinc-900 shadow-lg shadow-zinc-900/10 backdrop-blur-md dark:border-white/10 dark:bg-zinc-900/85 dark:text-zinc-100 dark:shadow-black/40",
          docked ? "max-w-[min(18rem,100%)] py-1.5 pr-3.5 pl-2.5" : "w-full max-w-md py-3 pr-3 pl-4",
        )}
      >
        <motion.svg
          layout="position"
          viewBox="0 0 16 16"
          fill="currentColor"
          className="size-4 shrink-0 text-violet-500 dark:text-violet-400"
          aria-hidden
        >
          <path d="M8 1.5 9.6 6.4 14.5 8l-4.9 1.6L8 14.5 6.4 9.6 1.5 8l4.9-1.6L8 1.5Z" />
        </motion.svg>
        <motion.span
          layout="position"
          className={cn("relative min-w-0 flex-1 truncate", docked ? "text-xs" : "text-sm")}
        >
          <motion.span>{typed}</motion.span>
          <motion.span className="text-zinc-400 dark:text-zinc-500">{placeholder}</motion.span>
          {phase === "typing" && (
            <span className="ml-px inline-block h-[1.1em] w-px translate-y-[0.2em] animate-pulse bg-current" />
          )}
        </motion.span>
        {!docked && (
          <motion.kbd
            layout="position"
            animate={phase === "sent" ? { scale: 0.88, y: 1 } : { scale: 1, y: 0 }}
            transition={{ type: "spring", visualDuration: 0.15, bounce: 0.3 }}
            className={cn(
              "grid size-7 shrink-0 place-items-center rounded-lg border font-sans text-sm transition-colors",
              phase === "sent"
                ? "border-violet-500 bg-violet-500 text-white"
                : "border-zinc-900/10 bg-zinc-900/5 text-zinc-500 dark:border-white/10 dark:bg-white/5 dark:text-zinc-400",
            )}
          >
            ↵
          </motion.kbd>
        )}
      </motion.div>
    </div>
  );
}
```

## Examples

Finished sections built with Blueprint. Each one installs whole, with the component included.

### Studio Process

A case study that goes from an annotated blueprint to the finished design as you scroll, with `play="manual"` following `useScroll`. `data-blueprint-order` groups the build into type, imagery and surfaces, and `onBlockChange` lights up the matching stage in the rail. Scroll back up and it comes apart.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/blueprint-process-demo
```

```tsx title="components/blueprint-process-demo.tsx"
"use client";

import * as React from "react";
import {
  motion,
  useReducedMotion,
  useScroll,
  useSpring,
  useTransform,
  type MotionValue,
} from "motion/react";

import { cn } from "@/lib/utils";
import { Blueprint, type BlueprintState } from "@/components/ui/blueprint";

// Each stage is a `data-blueprint-order` group: type first, then imagery, then surfaces.
const stages = [
  { title: "Wireframe", note: "Blocks measured from the page" },
  { title: "Type", note: "Copy set on its lines" },
  { title: "Imagery", note: "Photos develop in place" },
  { title: "Final", note: "Surfaces and borders fill in" },
];

const photo = (id: string, w: number, h: number) =>
  `https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=${w}&h=${h}&q=80`;

export function BlueprintProcessDemo() {
  const scrollRef = React.useRef<HTMLDivElement>(null);
  const trackRef = React.useRef<HTMLDivElement>(null);
  const reduced = useReducedMotion();
  const { scrollYProgress } = useScroll({
    container: scrollRef,
    target: trackRef,
    offset: ["start start", "end end"],
  });
  const eased = useSpring(scrollYProgress, { stiffness: 300, damping: 50, restDelta: 0.0005 });
  // A beat on the wireframe at the top and on the finished page at the bottom.
  const progress = useTransform(reduced ? scrollYProgress : eased, [0.06, 0.94], [0, 1]);
  const hint = useTransform(scrollYProgress, [0, 0.04], [1, 0]);

  // The furthest stage any block has started in. Scrolling back drops blocks out again.
  const [stage, setStage] = React.useState(0);
  const started = React.useRef(new Map<HTMLElement, number>());
  const onBlockChange = React.useCallback((element: HTMLElement, state: BlueprintState) => {
    const map = started.current;
    if (state === "wireframe") map.delete(element);
    else map.set(element, Number(element.dataset.blueprintOrder ?? 0) + 1);
    setStage(Math.max(0, ...map.values()));
  }, []);

  return (
    <div className="relative w-full overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
      <div ref={scrollRef} className="relative h-[var(--demo-height,36rem)] overflow-y-auto overscroll-contain">
        <div ref={trackRef} className="relative h-[320%]">
          <div className="sticky top-0 flex h-[var(--demo-height,36rem)] flex-col gap-4 p-4 sm:flex-row sm:gap-6 sm:p-6">
            <Rail stage={stage} progress={progress} hint={hint} />

            <Blueprint
              play="manual"
              progress={progress}
              annotate
              onBlockChange={onBlockChange}
              skeletonClassName="text-white"
              className="min-h-0 flex-1 overflow-hidden rounded-xl bg-[#1f4f96] bg-[linear-gradient(to_right,rgb(255_255_255/0.07)_1px,transparent_1px),linear-gradient(to_bottom,rgb(255_255_255/0.07)_1px,transparent_1px)] bg-[size:24px_24px] p-4 sm:p-6"
            >
              <CaseStudy />
            </Blueprint>
          </div>
        </div>
      </div>
    </div>
  );
}

function Rail({
  stage,
  progress,
  hint,
}: {
  stage: number;
  progress: MotionValue<number>;
  hint: MotionValue<number>;
}) {
  return (
    <div className="flex shrink-0 flex-col justify-between sm:w-44">
      <div>
        <p className="font-mono text-[11px] tracking-widest text-zinc-500 uppercase">Case study 07</p>
        <p className="mt-1 hidden text-sm font-medium tracking-tight text-zinc-900 sm:block dark:text-zinc-50">
          From wireframe to launch
        </p>
      </div>

      <ol className="relative mt-3 flex gap-4 sm:mt-0 sm:flex-col sm:gap-6">
        <span className="absolute top-1.5 bottom-1.5 left-[3.5px] hidden w-px bg-zinc-200 sm:block dark:bg-zinc-800" />
        <motion.span
          className="absolute top-1.5 bottom-1.5 left-[3.5px] hidden w-px origin-top bg-zinc-900 sm:block dark:bg-zinc-100"
          style={{ scaleY: progress }}
        />
        {stages.map((item, i) => (
          <li key={item.title} className="relative flex items-start gap-2.5 sm:gap-3">
            <span
              className={cn(
                "relative mt-1 size-2 shrink-0 rounded-full ring-4 transition-colors duration-300",
                i <= stage
                  ? "bg-zinc-900 ring-white dark:bg-zinc-100 dark:ring-zinc-950"
                  : "bg-zinc-300 ring-white dark:bg-zinc-700 dark:ring-zinc-950",
              )}
            />
            <div>
              <p
                className={cn(
                  "text-xs font-medium transition-colors duration-300 sm:text-sm",
                  i === stage
                    ? "text-zinc-900 dark:text-zinc-50"
                    : i < stage
                      ? "text-zinc-500"
                      : "text-zinc-400 dark:text-zinc-600",
                )}
              >
                {item.title}
              </p>
              <p
                className={cn(
                  "mt-0.5 hidden text-xs leading-snug transition-colors duration-300 sm:block",
                  i === stage ? "text-zinc-500" : "text-zinc-400 dark:text-zinc-600",
                )}
              >
                {item.note}
              </p>
            </div>
          </li>
        ))}
      </ol>

      <motion.p
        style={{ opacity: hint }}
        className="hidden items-center gap-1.5 text-xs text-zinc-500 sm:flex"
      >
        Scroll to build
        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" className="size-3.5" aria-hidden>
          <path d="M8 3v10m0 0 4-4m-4 4-4-4" strokeLinecap="round" strokeLinejoin="round" />
        </svg>
      </motion.p>
    </div>
  );
}

function CaseStudy() {
  return (
    <article
      data-blueprint="box"
      data-blueprint-order="2"
      data-blueprint-label="Page"
      className="flex h-full flex-col overflow-hidden rounded-lg bg-[#17130f] p-5 text-[#f1e7da] sm:p-6"
    >
      <header className="flex items-center justify-between gap-4">
        <span data-blueprint className="text-xs font-semibold tracking-[0.22em] uppercase">
          Ondas Coffee
        </span>
        <span data-blueprint className="font-mono text-[11px] text-[#f1e7da]/60">
          Brand · Web · 2026
        </span>
      </header>

      <div className="mt-7 grid gap-5 md:grid-cols-[1.1fr_1fr]">
        <div>
          <h2 data-blueprint className="text-2xl/tight font-semibold tracking-tight text-balance sm:text-3xl/tight">
            A roaster&apos;s site that smells like the shop
          </h2>
          <p data-blueprint className="mt-3 text-sm/6 text-pretty text-[#f1e7da]/70">
            We rebuilt Ondas around the Tuesday roast: one weekly drop, tasting notes from the roaster, and a
            checkout that takes under a minute.
          </p>
          <div className="mt-5 grid grid-cols-2 gap-3">
            <div
              data-blueprint="box"
              data-blueprint-order="2"
              className="rounded-lg border border-[#f1e7da]/15 bg-[#241d17] px-3 py-2.5"
            >
              <p data-blueprint data-blueprint-count className="text-2xl font-semibold tracking-tight tabular-nums">
                +48%
              </p>
              <p data-blueprint data-blueprint-label="" className="text-xs text-[#f1e7da]/60">
                online orders
              </p>
            </div>
            <div
              data-blueprint="box"
              data-blueprint-order="2"
              className="rounded-lg border border-[#f1e7da]/15 bg-[#241d17] px-3 py-2.5"
            >
              <p data-blueprint data-blueprint-count className="text-2xl font-semibold tracking-tight tabular-nums">
                3.1×
              </p>
              <p data-blueprint data-blueprint-label="" className="text-xs text-[#f1e7da]/60">
                repeat buyers
              </p>
            </div>
          </div>
        </div>
        <img
          data-blueprint
          data-blueprint-order="1"
          src={photo("1442512595331-e89e73853f31", 720, 540)}
          alt="Coffee dripping through a pour-over into a glass carafe"
          className="hidden aspect-[4/3] w-full rounded-lg object-cover md:block"
        />
      </div>

      <div className="mt-5 hidden min-h-24 flex-1 grid-cols-3 gap-3 sm:grid">
        <img
          data-blueprint
          data-blueprint-order="1"
          src={photo("1447933601403-0c6688de566e", 480, 300)}
          alt="Roasted coffee beans"
          className="h-full max-h-56 w-full rounded-lg object-cover"
        />
        <img
          data-blueprint
          data-blueprint-order="1"
          src={photo("1497935586351-b67a49e012bf", 480, 300)}
          alt="A latte, a pour-over and ground coffee on a wooden board"
          className="h-full max-h-56 w-full rounded-lg object-cover"
        />
        <a
          data-blueprint
          data-blueprint-order="2"
          href="#case-study"
          className="flex h-full max-h-56 flex-col justify-between rounded-lg bg-[#e8a35a] p-3 text-sm font-medium text-[#17130f]"
        >
          <span>Read the case study</span>
          <span aria-hidden>→</span>
        </a>
      </div>
    </article>
  );
}
```

### Generated Dashboard

An analytics assistant answers with a dashboard card. KPIs count up through `data-blueprint-count`, chart bars grow from even placeholders into their real heights through `--blueprint-progress`, and table rows fill in cell by cell. Drag the scrubber to take it apart and put it back.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/blueprint-dashboard-demo
```

```tsx title="components/blueprint-dashboard-demo.tsx"
import { Blueprint } from "@/components/ui/blueprint";

const regions = [
  { name: "North America", short: "NA", revenue: 1.64, change: "+14.2%" },
  { name: "Europe", short: "EU", revenue: 1.38, change: "+9.8%" },
  { name: "Asia Pacific", short: "APAC", revenue: 1.02, change: "+21.5%" },
  { name: "Latin America", short: "LATAM", revenue: 0.49, change: "+6.1%" },
  { name: "Middle East & Africa", short: "MEA", revenue: 0.29, change: "+3.4%" },
];

const kpis = [
  { label: "Revenue", value: "$4.82M" },
  { label: "vs. Q2", value: "+12.4%" },
  { label: "Orders", value: "18,240" },
];

const top = Math.max(...regions.map((r) => r.revenue));
// In the wireframe every bar stands at the same placeholder height, then grows or settles
// into its real one as it builds. --blueprint-progress runs 0–1 on each block while it builds.
const SKELETON_BAR = 0.42;
const barStyle = (revenue: number) => {
  const height = revenue / top;
  const from = SKELETON_BAR / height;
  return {
    height: `${height * 100}%`,
    scale: `1 calc(${from} + ${1 - from} * var(--blueprint-progress, 1))`,
  };
};

export function BlueprintDashboardDemo() {
  return (
    <div className="w-full max-w-2xl rounded-2xl border border-zinc-200 bg-zinc-50 p-4 text-zinc-900 sm:p-5 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100">
      <div className="flex justify-end">
        <p className="rounded-2xl rounded-br-md bg-zinc-900 px-3.5 py-2 text-sm text-white dark:bg-white dark:text-zinc-900">
          Revenue by region, last quarter
        </p>
      </div>
      <p className="mt-4 mb-3 flex items-center gap-2 text-xs text-zinc-500">
        <svg viewBox="0 0 16 16" fill="currentColor" className="size-3.5 text-indigo-500" aria-hidden>
          <path d="M8 1.5 9.6 6.4 14.5 8l-4.9 1.6L8 14.5 6.4 9.6 1.5 8l4.9-1.6L8 1.5Z" />
        </svg>
        Built from 4 queries on orders and accounts
      </p>

      <Blueprint play="auto" scrubber duration={{ build: 6, hold: 3 }}>
        <div
          data-blueprint="box"
          data-blueprint-label="Card"
          className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm sm:p-5 dark:border-zinc-800 dark:bg-zinc-900"
        >
          <div className="flex items-start justify-between gap-4">
            <div>
              <h3 data-blueprint className="text-base font-semibold tracking-tight">
                Revenue by region
              </h3>
              <p data-blueprint className="mt-0.5 text-xs text-zinc-500">
                Q3 2026 · Jul 1 – Sep 30
              </p>
            </div>
            <button
              data-blueprint
              type="button"
              className="rounded-md border border-zinc-200 px-2.5 py-1 text-xs font-medium dark:border-zinc-700"
            >
              Export
            </button>
          </div>

          <div className="mt-4 grid grid-cols-3 gap-2 sm:gap-3">
            {kpis.map((kpi) => (
              <div key={kpi.label} className="rounded-lg bg-zinc-50 px-3 py-2.5 dark:bg-zinc-800/60">
                <p data-blueprint className="text-xs text-zinc-500">
                  {kpi.label}
                </p>
                <p
                  data-blueprint
                  data-blueprint-count
                  className="mt-0.5 text-lg font-semibold tracking-tight tabular-nums sm:text-xl"
                >
                  {kpi.value}
                </p>
              </div>
            ))}
          </div>

          <div className="mt-5 flex h-32 items-end gap-3 border-b border-zinc-200 px-1 sm:gap-5 dark:border-zinc-800">
            {regions.map((region, i) => (
              <div
                key={region.short}
                data-blueprint="box"
                className={
                  i === 0
                    ? "flex-1 origin-bottom rounded-t-md bg-indigo-500 dark:bg-indigo-400"
                    : "flex-1 origin-bottom rounded-t-md bg-indigo-500/45 dark:bg-indigo-400/45"
                }
                style={barStyle(region.revenue)}
              />
            ))}
          </div>
          <div data-blueprint="text" className="mt-1.5 flex gap-3 px-1 text-[11px] text-zinc-500 sm:gap-5">
            {regions.map((region) => (
              <span key={region.short} className="flex-1 text-center">
                {region.short}
              </span>
            ))}
          </div>

          <div className="mt-5 text-sm">
            <div
              data-blueprint="text"
              className="grid grid-cols-[1fr_5rem_4rem] gap-x-4 pb-2 text-[11px] font-medium tracking-wide text-zinc-500 uppercase"
            >
              <span>Region</span>
              <span className="text-right">Revenue</span>
              <span className="text-right">Change</span>
            </div>
            {regions.map((region) => (
              <div
                key={region.short}
                data-blueprint="text"
                data-blueprint-count
                className="grid grid-cols-[1fr_5rem_4rem] gap-x-4 border-t border-zinc-100 py-2 tabular-nums dark:border-zinc-800"
              >
                <span className="truncate">{region.name}</span>
                <span className="text-right">${region.revenue.toFixed(2)}M</span>
                <span className="text-right text-emerald-600 dark:text-emerald-400">{region.change}</span>
              </div>
            ))}
          </div>
        </div>
      </Blueprint>
    </div>
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` (required) | `React.ReactNode` |  | Your markup. Every element marked with `data-blueprint` becomes a block; unmarked content shows as it is from the start. |
| `play` | `"auto" \| "scroll" \| "manual"` | `"auto"` | `auto` rests on the wireframe, builds, holds the page, comes apart and starts over, pausing on hover and off screen. `scroll` builds as the section scrolls into view, finishing when it's centered. `manual` follows `progress`. |
| `progress` | `number \| MotionValue<number>` |  | Build progress in `manual` mode, from 0 (wireframe) to 1 (finished page). Pass a MotionValue, such as one from `useScroll`, to drive it without re-rendering. |
| `onProgressChange` | `(progress: number) => void` |  | Called whenever Blueprint moves the progress itself: the auto loop, scrolling, or the scrubber. In `manual` mode only the scrubber calls it, so feed the value back into `progress`. |
| `duration` | `number \| { build?: number; hold?: number; unbuild?: number; rest?: number }` | `5` | Seconds the build takes in `auto` mode, or each part of the loop: the build (5), the hold on the finished page (2.5), coming apart (1) and the rest on the wireframe (0.8). |
| `onLoop` | `(loop: number) => void` |  | Called as each auto loop starts on the wireframe, with the loop count (0 first). Use it to swap content between builds; the wireframe re-measures to match. |
| `onBlockChange` | `(element: HTMLElement, state: "wireframe" \| "building" \| "built") => void` |  | Called when a marked element starts building, finishes, or goes back to its wireframe. The same state is on the element as `data-blueprint-state` for styling. |
| `annotate` | `boolean` | `false` | Put a small spec label beside each block until it's built, read from its computed styles: "H1 · 56/60", "Image 16:9", "Button". |
| `scrubber` | `boolean` | `false` | Show a scrubber under the stage, with a tick where each block starts. Drag or use the arrow keys to build and unbuild by hand; in `auto` mode the loop carries on from where you let go. |
| `className` | `string` |  | Classes for the stage, the positioned box your markup and the skeletons share. |
| `skeletonClassName` | `string` |  | Classes for the skeleton layer. Bars, boxes, outlines and labels are all drawn from its text color, so `text-white` turns the wireframe white on a dark or blue stage. |
| `data-blueprint` | `"" \| "text" \| "image" \| "button" \| "box"` |  | Attribute on your markup that makes an element a block. Left empty, the kind is guessed: images and video are `image`, buttons and filled links are `button`, elements with only inline text are `text`, anything else is a `box`. Only boxes can hold other blocks. |
| `data-blueprint-order` | `number` | `0` | Attribute that moves a block later in the build. Blocks build in order of this number, then in reading order, so `1` on every image builds all the text first. |
| `data-blueprint-label` | `string` |  | Attribute that replaces a block's annotation. An empty value hides it. |
| `data-blueprint-count` | `boolean` |  | Attribute for text blocks: numbers in it count up from zero as they stream in, keeping their format ("$4.82M", "18,240", "+12.4%"). |

## Source

```tsx title="components/ui/blueprint.tsx"
"use client";

import * as React from "react";
import {
  animate,
  isMotionValue,
  useMotionValue,
  useMotionValueEvent,
  useReducedMotion,
  useScroll,
  useSpring,
  type AnimationPlaybackControls,
  type MotionValue,
} from "motion/react";

import { cn } from "@/lib/utils";

/* ─────────────────────────────────────────────────────────
 * BLUEPRINT STORYBOARD
 *
 *  wireframe  every [data-blueprint] part is measured from the
 *             real markup and drawn as a skeleton: text as one
 *             bar per rendered line, images as boxes with a thin
 *             ✕, buttons as pills, avatars as circles, surfaces
 *             in gray, outlined if they have a border. The page is
 *             laid out underneath from the first frame, so nothing
 *             moves as it builds
 *  build      one timeline from 0 to 1. Each block takes a slice
 *             of it that overlaps its neighbors, in reading order
 *    text     words stream in over their bars, each from a slight
 *             blur to sharp, and the bar retreats ahead of them
 *             like a print head. Headlines carry a caret
 *    image    the ✕ fades, then the photo develops from blurred
 *             and gray to sharp full color, settling from a few
 *             percent larger inside its frame
 *    button   pops in on a soft spring with the smallest settle
 *    box      fills from gray to its real color while its border
 *             draws in around it, then its shadow comes up
 *  annotate   a small spec label sits beside each block until
 *             it's built
 *  scrub      every frame is a pure function of progress, so
 *             scrubbing back takes the page apart exactly
 *  auto       rest on the wireframe, build, hold the page, come
 *             apart, again. Hovering pauses it, and so does being
 *             off screen or in a background tab
 *  scroll     progress follows the section scrolling into view
 *  reduced    no streaming, blur or pops: each block fades from
 *             skeleton to content in turn. Auto mode shows the
 *             finished page with a Replay button
 * ───────────────────────────────────────────────────────── */

const BLUEPRINT = {
  build: 5, // s from wireframe to finished page
  hold: 2.5, // s the finished page stays up
  unbuild: 1, // s to come apart
  rest: 0.8, // s on the wireframe before the next build
  lead: 0.5, // the next block starts once the one before is this far along
  weight: { word: 0.05, text: [0.6, 1.8], image: 1.2, avatar: 0.6, button: 0.6, box: 0.7 },
  span: 6, // characters behind the print head a word takes to come into focus
  blur: 0.12, // em a word starts blurred by
  bar: 0.6, // em, height of a text bar
  split: 1.2, // em; a wider gap between words starts a new bar (table cells, nav links)
  develop: { blur: 14, saturate: 0.1, zoom: 0.05 }, // px max blur, starting saturation, extra scale
  pop: { from: 0.82, damping: 0.72, decay: 6 }, // decay is ζω: the spring settles by the end of its slice
  scroll: { stiffness: 400, damping: 60, restDelta: 0.0005 },
  replay: 0.6, // a reduced-motion replay runs this much of the build duration
} as const;

// Skeleton tones, all from the layer's text color, so `skeletonClassName="text-white"` restyles every part.
const TONE = {
  bar: "color-mix(in oklab, currentColor 22%, transparent)",
  fill: "color-mix(in oklab, currentColor 14%, transparent)",
  line: "color-mix(in oklab, currentColor 38%, transparent)",
  chip: "color-mix(in oklab, currentColor 10%, transparent)",
};
const SURFACE = 9; // % of the skeleton color a box's surface starts at

export type BlueprintKind = "text" | "image" | "button" | "box";
export type BlueprintState = "wireframe" | "building" | "built";
export type BlueprintTiming = { build: number; hold: number; unbuild: number; rest: number };

/* ── Math ───────────────────────────────────────────────── */

const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
const smooth = (v: number) => {
  const t = clamp01(v);
  return t * t * (3 - 2 * t);
};
const easeOut = (v: number) => 1 - (1 - clamp01(v)) ** 3;
const easeInOut = (v: number) => {
  const t = clamp01(v);
  return t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
};
// A damped spring from 0 to 1 over t = 0…1, solved in closed form so it can be scrubbed.
const spring = (t: number) => {
  if (t <= 0) return 0;
  if (t >= 1) return 1;
  const { damping, decay } = BLUEPRINT.pop;
  const wd = (decay / damping) * Math.sqrt(1 - damping * damping);
  return 1 - Math.exp(-decay * t) * (Math.cos(wd * t) + (decay / wd) * Math.sin(wd * t));
};
const fix = (v: number, digits = 3) => String(Number(v.toFixed(digits)));
// Within a hair of 1, a scale is dropped, so text and edges don't re-rasterize when the block hands back.
const scaleOf = (v: number) => (Math.abs(v - 1) < 0.002 ? "none" : fix(v, 4));

function timingOf(duration: BlueprintProps["duration"]): BlueprintTiming {
  const own = typeof duration === "number" ? { build: duration } : (duration ?? {});
  return {
    build: own.build ?? BLUEPRINT.build,
    hold: own.hold ?? BLUEPRINT.hold,
    unbuild: own.unbuild ?? BLUEPRINT.unbuild,
    rest: own.rest ?? BLUEPRINT.rest,
  };
}

// One auto cycle: rest on the wireframe, build, hold, come apart.
function progressAt(t: number, { rest, build, hold, unbuild }: BlueprintTiming) {
  if (t < rest) return 0;
  if (t < rest + build) return (t - rest) / build;
  if (t < rest + build + hold) return 1;
  return 1 - easeInOut((t - rest - build - hold) / unbuild);
}

/* ── Measuring ──────────────────────────────────────────── */

type Rect = { x: number; y: number; w: number; h: number };

type Count = { prefix: string; value: number; decimals: number; group: boolean; suffix: string };

type Word = Rect & {
  text: string;
  /** Characters before this word, counting one per gap. The print head moves in characters. */
  c0: number;
  size: number;
  css: string;
  /** Opacity the real text is drawn at, from its own and its ancestors' opacity. */
  alpha: number;
  count?: Count;
  /** The edge a counting number holds as its width changes. */
  anchor: Anchor;
};

type Line = Rect & { c0: number; c1: number; words: number[]; bar: Rect };

type Base = {
  el: HTMLElement;
  id: number;
  /** Position in reading order, before `data-blueprint-order` reshuffles the build. */
  dom: number;
  rect: Rect;
  radius: string;
  label: string;
  start: number;
  end: number;
};

type Block =
  | (Base & { kind: "text"; words: Word[]; lines: Line[]; chars: number; caret: string | null })
  | (Base & { kind: "image"; round: boolean; blur: number })
  | (Base & { kind: "button"; lines: Line[] })
  | (Base & {
      kind: "box";
      background: string;
      border: string;
      borderWidth: number;
      shadow: string;
      surface: string;
    });

type Anchor = "left" | "center" | "right";

const KINDS: BlueprintKind[] = ["text", "image", "button", "box"];
const WORD = /\S+/g;
const NUMBER = /^(\D*?)(\d[\d,]*(?:\.\d+)?)(.*)$/;
const COLOR = /(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\([^()]*\)|#[0-9a-f]{3,8}\b/gi;
const PX = /(\d*\.?\d+(?:e[+-]?\d+)?)px/g;
const RATIOS = [
  [1, 1], [4, 3], [3, 4], [3, 2], [2, 3], [16, 9], [9, 16], [4, 5], [5, 4], [21, 9], [2, 1],
];
// Text properties a word's stand-in copies, so it lands on the real glyphs.
const TEXT_STYLE = [
  "font-family", "font-size", "font-weight", "font-style", "font-stretch", "font-variant",
  "font-feature-settings", "font-variation-settings", "font-kerning", "font-optical-sizing",
  "letter-spacing", "text-transform", "text-decoration", "text-shadow", "color",
  "-webkit-font-smoothing", "-moz-osx-font-smoothing", "text-rendering",
];

const ids = new WeakMap<Element, number>();
let nextId = 0;
const idOf = (el: Element) => {
  let id = ids.get(el);
  if (id === undefined) ids.set(el, (id = nextId++));
  return id;
};

const isClear = (color: string) =>
  color === "transparent" || /^rgba\(.*,\s*0\)$/.test(color) || /\/\s*0\)$/.test(color);

const anchorOf = (align: string): Anchor =>
  align === "center" ? "center" : /right|end/.test(align) ? "right" : "left";

function kindOf(el: HTMLElement): BlueprintKind {
  const own = el.getAttribute("data-blueprint") as BlueprintKind;
  if (KINDS.includes(own)) return own;
  const tag = el.tagName.toUpperCase();
  if (/^(IMG|PICTURE|VIDEO|CANVAS|SVG|IFRAME)$/.test(tag)) return "image";
  if (tag === "BUTTON" || el.getAttribute("role") === "button") return "button";
  if (tag === "INPUT") {
    return /^(button|submit|reset)$/.test((el as HTMLInputElement).type) ? "button" : "box";
  }
  const style = getComputedStyle(el);
  const text = Boolean(el.textContent?.trim());
  if (tag === "A" && style.display !== "inline") {
    if (!isClear(style.backgroundColor) || parseFloat(style.borderTopWidth) > 0) return "button";
  }
  if (!text && style.backgroundImage !== "none") return "image";
  const blocky = Array.from(el.children).some(
    (child) => !getComputedStyle(child).display.startsWith("inline"),
  );
  return text && !blocky ? "text" : "box";
}

function radiusOf(el: HTMLElement, rect: DOMRect) {
  const read = (s: CSSStyleDeclaration) =>
    [s.borderTopLeftRadius, s.borderTopRightRadius, s.borderBottomRightRadius, s.borderBottomLeftRadius].join(" ");
  const own = getComputedStyle(el);
  if (parseFloat(own.borderTopLeftRadius) || parseFloat(own.borderBottomRightRadius)) return read(own);
  // An image cropped by a rounded wrapper takes the wrapper's corners.
  let parent = el.parentElement;
  for (let i = 0; parent && i < 3; i++, parent = parent.parentElement) {
    const r = parent.getBoundingClientRect();
    if (Math.abs(r.width - rect.width) > 1 || Math.abs(r.height - rect.height) > 1) break;
    const s = getComputedStyle(parent);
    if (s.overflow !== "visible" && parseFloat(s.borderTopLeftRadius)) return read(s);
  }
  return read(own);
}

function ratioOf(w: number, h: number) {
  const r = w / h;
  const hit = RATIOS.find(([a, b]) => Math.abs(a / b - r) / r < 0.03);
  return hit ? `${hit[0]}:${hit[1]}` : `${Math.round(w)}×${Math.round(h)}`;
}

function labelOf(el: HTMLElement, kind: BlueprintKind, rect: Rect, round: boolean) {
  const own = el.getAttribute("data-blueprint-label");
  if (own !== null) return own;
  if (kind === "image") return round ? "Avatar" : `Image ${ratioOf(rect.w, rect.h)}`;
  if (kind === "button") return "Button";
  if (kind === "box") return `Box ${Math.round(rect.w)}×${Math.round(rect.h)}`;
  const style = getComputedStyle(el);
  const tag = el.tagName.toUpperCase();
  const name = /^H[1-6]$/.test(tag) ? tag : tag === "P" ? "Body" : "Text";
  const size = Math.round(parseFloat(style.fontSize));
  const leading = style.lineHeight === "normal" ? "" : `/${Math.round(parseFloat(style.lineHeight))}`;
  return `${name} · ${size}${leading}`;
}

function parseCount(text: string): Count | undefined {
  const match = NUMBER.exec(text);
  if (!match) return undefined;
  const [, prefix, digits, suffix] = match;
  return {
    prefix,
    value: parseFloat(digits.replace(/,/g, "")),
    decimals: digits.split(".")[1]?.length ?? 0,
    group: digits.includes(","),
    suffix,
  };
}

function countText({ prefix, value, decimals, group, suffix }: Count, t: number) {
  const n = (value * t).toLocaleString("en-US", {
    minimumFractionDigits: decimals,
    maximumFractionDigits: decimals,
    useGrouping: group,
  });
  return prefix + n + suffix;
}

/** Splits a text element into words with their rendered boxes, then groups them into bars. */
function readText(el: HTMLElement, stage: HTMLElement, toRect: (r: DOMRect) => Rect, count: boolean) {
  type Source = {
    css: string;
    size: number;
    skip: boolean;
    clip: HTMLElement | null;
    anchor: Anchor;
    alpha: number;
  };
  const sources = new Map<Element, Source>();
  const sourceOf = (parent: HTMLElement): Source => {
    let source = sources.get(parent);
    if (source) return source;
    const style = getComputedStyle(parent);
    const r = parent.getBoundingClientRect();
    // Gradient text paints the background of the element that clips it, so the stand-ins borrow it.
    let clip: HTMLElement | null = null;
    for (let node: HTMLElement | null = parent; node; node = node.parentElement) {
      const s = getComputedStyle(node);
      if (s.backgroundClip === "text" || s.getPropertyValue("-webkit-background-clip") === "text") {
        clip = node;
        break;
      }
      if (node === el) break;
    }
    let alpha = 1;
    for (let node: HTMLElement | null = parent; node && node !== stage; node = node.parentElement) {
      alpha *= parseFloat(getComputedStyle(node).opacity);
    }
    source = {
      css: TEXT_STYLE.map((p) => {
        const v = style.getPropertyValue(p);
        return v ? `${p}:${v};` : "";
      }).join(""),
      size: parseFloat(style.fontSize) || 16,
      // Screen-reader-only and hidden text keeps its box but doesn't paint.
      skip: style.visibility !== "visible" || r.width <= 1 || r.height <= 1,
      clip,
      anchor: anchorOf(style.textAlign),
      alpha,
    };
    sources.set(parent, source);
    return source;
  };

  const words: Word[] = [];
  const range = document.createRange();
  const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
  let chars = 0;
  for (let node = walker.nextNode(); node; node = walker.nextNode()) {
    const text = node as Text;
    const parent = text.parentElement;
    if (!parent) continue;
    const source = sourceOf(parent);
    if (source.skip) continue;
    for (const match of text.data.matchAll(WORD)) {
      range.setStart(text, match.index);
      range.setEnd(text, match.index + match[0].length);
      const r = range.getClientRects()[0];
      if (!r || r.width === 0) continue;
      const rect = toRect(r);
      let css = source.css;
      if (source.clip) {
        const style = getComputedStyle(source.clip);
        const box = toRect(source.clip.getBoundingClientRect());
        css +=
          `background-image:${style.backgroundImage};background-repeat:no-repeat;` +
          `background-size:${box.w}px ${box.h}px;` +
          `background-position:${box.x - rect.x}px ${box.y - rect.y}px;` +
          "-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;";
      }
      words.push({
        ...rect,
        text: match[0],
        c0: chars,
        size: source.size,
        css,
        alpha: source.alpha,
        count: count ? parseCount(match[0]) : undefined,
        anchor: source.anchor,
      });
      chars += match[0].length + 1;
    }
  }
  return { words, lines: linesOf(words), chars: Math.max(chars - 1, 0) };
}

function linesOf(words: Word[]): Line[] {
  const lines: Line[] = [];
  let line: Line | undefined;
  words.forEach((w, i) => {
    const prev = line && words[line.words[line.words.length - 1]];
    const right = prev ? prev.x + prev.w : 0;
    const joins =
      prev &&
      Math.abs(w.y + w.h / 2 - (prev.y + prev.h / 2)) < Math.min(w.h, prev.h) / 2 &&
      w.x >= right - 1 &&
      w.x - right < BLUEPRINT.split * Math.max(w.size, prev.size);
    if (!line || !joins) {
      line = { x: w.x, y: w.y, w: w.w, h: w.h, c0: w.c0, c1: w.c0 + w.text.length, words: [i], bar: w };
      lines.push(line);
      return;
    }
    const r = Math.max(line.x + line.w, w.x + w.w);
    const b = Math.max(line.y + line.h, w.y + w.h);
    line.y = Math.min(line.y, w.y);
    line.w = r - line.x;
    line.h = b - line.y;
    line.c1 = w.c0 + w.text.length;
    line.words.push(i);
  });
  for (const l of lines) {
    const h = Math.min(l.h, words[l.words[0]].size * BLUEPRINT.bar);
    l.bar = { x: l.x, y: l.y + (l.h - h) / 2, w: l.w, h };
  }
  return lines;
}

/** Where the print head sits along a line, in px, when it has passed `head` characters. */
function xAt(words: Word[], line: Line, head: number) {
  let right = line.x;
  let end = line.c0;
  for (const i of line.words) {
    const w = words[i];
    if (head < w.c0) return right + (w.x - right) * clamp01((head - end) / Math.max(w.c0 - end, 1e-6));
    const stop = w.c0 + w.text.length;
    if (head <= stop) return w.x + (w.w * (head - w.c0)) / w.text.length;
    right = w.x + w.w;
    end = stop;
  }
  return right;
}

function weightOf(block: Block) {
  const { weight } = BLUEPRINT;
  if (block.kind === "text") {
    const [min, max] = weight.text;
    return Math.min(Math.max(0.45 + weight.word * block.words.length, min), max);
  }
  if (block.kind === "image") return block.round ? weight.avatar : weight.image;
  return weight[block.kind];
}

function readLayout(stage: HTMLElement, layer: HTMLElement): Block[] {
  const origin = stage.getBoundingClientRect();
  const zoom = origin.width / (stage.offsetWidth || origin.width) || 1; // a scaled ancestor
  const dx = origin.left + stage.clientLeft * zoom - stage.scrollLeft * zoom;
  const dy = origin.top + stage.clientTop * zoom - stage.scrollTop * zoom;
  const toRect = (r: DOMRect): Rect => ({
    x: (r.left - dx) / zoom,
    y: (r.top - dy) / zoom,
    w: r.width / zoom,
    h: r.height / zoom,
  });
  const ink = getComputedStyle(layer).color;

  const kinds = new Map<HTMLElement, BlueprintKind>();
  const kind = (el: HTMLElement) => {
    let k = kinds.get(el);
    if (!k) kinds.set(el, (k = kindOf(el)));
    return k;
  };
  const marked = Array.from(stage.querySelectorAll<HTMLElement>("[data-blueprint]")).filter((el) => {
    if (layer.contains(el) || !el.getClientRects().length) return false;
    // Only boxes hold other blocks.
    for (let p = el.parentElement; p && p !== stage; p = p.parentElement) {
      if (p.hasAttribute("data-blueprint") && kind(p) !== "box") return false;
    }
    return true;
  });
  const dom = new Map(marked.map((el, i) => [el, i]));
  const order = (el: HTMLElement) => Number(el.getAttribute("data-blueprint-order")) || 0;
  marked.sort((a, b) => order(a) - order(b)); // stable, so reading order holds within an order

  const blocks = marked.map((el): Block => {
    const k = kind(el);
    const box = el.getBoundingClientRect();
    const rect = toRect(box);
    const radius = radiusOf(el, box);
    const round = Math.abs(rect.w - rect.h) < 2 && parseFloat(radius) >= Math.min(rect.w, rect.h) / 2 - 0.5;
    const base = {
      el,
      id: idOf(el),
      dom: dom.get(el) ?? 0,
      rect,
      radius,
      label: labelOf(el, k, rect, round),
      start: 0,
      end: 1,
    };
    if (k === "image") {
      // Blur scales with the photo, so a small one doesn't turn to mush.
      const blur = Math.min(BLUEPRINT.develop.blur, Math.min(rect.w, rect.h) * 0.035);
      return { ...base, kind: k, round, blur };
    }
    if (k === "button") return { ...base, kind: k, lines: readText(el, stage, toRect, false).lines };
    if (k === "box") {
      const s = getComputedStyle(el);
      return {
        ...base,
        kind: k,
        background: s.backgroundColor,
        border: s.borderTopColor,
        borderWidth: parseFloat(s.borderTopWidth) || 0,
        shadow: s.boxShadow,
        surface: `color-mix(in oklab, ${ink} ${SURFACE}%, transparent)`,
      };
    }
    const s = getComputedStyle(el);
    const text = readText(el, stage, toRect, el.closest("[data-blueprint-count]") !== null);
    const size = parseFloat(s.fontSize);
    const tag = el.tagName.toUpperCase();
    return {
      ...base,
      kind: k,
      ...text,
      caret: tag === "H1" || tag === "H2" || size >= 28 ? s.color : null,
    };
  });

  // Overlapping slices: each block starts once the one before is `lead` of the way through.
  let t = 0;
  const spans = blocks.map((block) => {
    const w = weightOf(block);
    const span = [t, t + w];
    t += w * BLUEPRINT.lead;
    return span;
  });
  const total = Math.max(1e-6, ...spans.map(([, end]) => end));
  blocks.forEach((block, i) => {
    block.start = spans[i][0] / total;
    block.end = spans[i][1] / total;
  });
  return blocks;
}

/* ── Painting ───────────────────────────────────────────── */

type Nodes = {
  group: HTMLElement;
  bars: HTMLElement[];
  words: HTMLElement | null;
  wordNodes: HTMLElement[];
  caret: HTMLElement | null;
  part: HTMLElement | null;
  cross: SVGElement | null;
  draw: SVGRectElement | null;
  label: HTMLElement | null;
  /** The label sits over the block's own skeleton, so it leaves as soon as the block starts. */
  inside: boolean;
};

type Style = Partial<Record<keyof CSSStyleDeclaration, string>>;
const SVG = "http://www.w3.org/2000/svg";
const px = (v: number) => `${v}px`;
const place = ({ x, y, w, h }: Rect): Style => ({ left: px(x), top: px(y), width: px(w), height: px(h) });

function make(parent: Element, style: Style = {}) {
  const el = document.createElement("div");
  el.style.position = "absolute";
  Object.assign(el.style, style);
  parent.appendChild(el);
  return el;
}

/** Writes a style only when it changes. */
function put(el: HTMLElement | SVGElement | null, prop: string, value: string) {
  if (el && el.style.getPropertyValue(prop) !== value) el.style.setProperty(prop, value);
}

// Inline styles Blueprint has written on the real page, with the values they replaced.
const originals = new WeakMap<HTMLElement, Map<string, string>>();

function paint(el: HTMLElement, prop: string, value: string) {
  let saved = originals.get(el);
  if (!saved) originals.set(el, (saved = new Map()));
  if (!saved.has(prop)) saved.set(prop, el.style.getPropertyValue(prop));
  put(el, prop, value);
}

function unpaint(el: HTMLElement) {
  const saved = originals.get(el);
  if (!saved) return;
  for (const [prop, value] of saved) {
    if (value) el.style.setProperty(prop, value);
    else el.style.removeProperty(prop);
  }
  originals.delete(el);
}

const mix = (real: string, t: number, from: string) =>
  t <= 0 ? from : `color-mix(in oklab, ${real} ${fix(t * 100, 2)}%, ${from})`;
const fadeColors = (value: string, t: number) =>
  value.replace(COLOR, (c) => `color-mix(in srgb, ${c} ${fix(t * 100, 2)}%, transparent)`);

function build(layer: HTMLElement, blocks: Block[], annotate: boolean, width: number): Nodes[] {
  layer.replaceChildren();
  const nodes = blocks.map((block) => {
    const group = make(layer, { inset: "0", display: "none" });
    const n: Nodes = {
      group,
      bars: [],
      words: null,
      wordNodes: [],
      caret: null,
      part: null,
      cross: null,
      draw: null,
      label: null,
      inside: false,
    };
    const { rect, radius } = block;
    const bar = (parent: Element, r: Rect, offset = { x: 0, y: 0 }) =>
      make(parent, {
        ...place({ ...r, x: r.x - offset.x, y: r.y - offset.y }),
        borderRadius: px(Math.min(r.h / 2, 6)),
        background: TONE.bar,
      });

    if (block.kind === "text") {
      n.bars = block.lines.map((line) => bar(group, line.bar));
      n.words = make(group, { inset: "0", display: "none" });
      n.wordNodes = block.words.map((w) => {
        const span = document.createElement("span");
        // Counting numbers change width, so they hold the edge the text is aligned to.
        const edge =
          w.count && w.anchor === "right"
            ? `right:${width - w.x - w.w}px;`
            : w.count && w.anchor === "center"
              ? `left:${w.x + w.w / 2}px;translate:-50% 0;`
              : `left:${w.x}px;`;
        span.style.cssText =
          `position:absolute;${edge}top:${w.y}px;height:${w.h}px;line-height:${w.h}px;` +
          `white-space:pre;text-indent:0;word-spacing:normal;opacity:0;${w.css}`;
        span.textContent = w.text;
        n.words!.appendChild(span);
        return span;
      });
      if (block.caret) {
        n.caret = make(group, {
          left: "0",
          top: "0",
          width: px(Math.max(2, Math.round((block.words[0]?.size ?? 16) * 0.05))),
          borderRadius: "1px",
          background: block.caret,
          opacity: "0",
        });
      }
    } else if (block.kind === "image") {
      n.part = make(group, {
        ...place(rect),
        borderRadius: radius,
        overflow: "hidden",
        background: TONE.fill,
        boxShadow: `inset 0 0 0 1px ${TONE.line}`,
      });
      if (!block.round && Math.min(rect.w, rect.h) >= 40) {
        const svg = document.createElementNS(SVG, "svg");
        svg.setAttribute("viewBox", `0 0 ${rect.w} ${rect.h}`);
        Object.assign(svg.style, { position: "absolute", inset: "0", width: "100%", height: "100%" });
        const path = document.createElementNS(SVG, "path");
        path.setAttribute("d", `M0 0L${rect.w} ${rect.h}M${rect.w} 0L0 ${rect.h}`);
        path.setAttribute("stroke", TONE.line);
        path.setAttribute("stroke-width", "1");
        path.setAttribute("fill", "none");
        svg.appendChild(path);
        n.part.appendChild(svg);
        n.cross = svg;
      }
    } else if (block.kind === "button") {
      n.part = make(group, {
        ...place(rect),
        borderRadius: radius,
        background: TONE.fill,
        boxShadow: `inset 0 0 0 1px ${TONE.line}`,
      });
      for (const line of block.lines) bar(n.part, line.bar, rect);
    } else if (block.borderWidth > 0) {
      // A bordered box gets a wireframe outline, then its real border draws in over it.
      // Borderless boxes (a page, a chart bar) are just their gray surface.
      n.part = make(group, {
        ...place(rect),
        borderRadius: radius,
        boxShadow: `inset 0 0 0 1px ${TONE.line}`,
      });
      const bw = block.borderWidth;
      const svg = document.createElementNS(SVG, "svg");
      Object.assign(svg.style, { position: "absolute", overflow: "visible", ...place(rect) });
      const outline = document.createElementNS(SVG, "rect");
      const r = Math.min(parseFloat(radius) || 0, rect.w / 2, rect.h / 2);
      outline.setAttribute("x", String(bw / 2));
      outline.setAttribute("y", String(bw / 2));
      outline.setAttribute("width", String(Math.max(rect.w - bw, 0)));
      outline.setAttribute("height", String(Math.max(rect.h - bw, 0)));
      outline.setAttribute("rx", String(Math.max(r - bw / 2, 0)));
      outline.setAttribute("pathLength", "1");
      outline.setAttribute("fill", "none");
      outline.setAttribute("stroke", block.border);
      outline.setAttribute("stroke-width", String(bw));
      outline.style.strokeDasharray = "1 2";
      outline.style.strokeDashoffset = "1";
      svg.appendChild(outline);
      group.appendChild(svg);
      n.draw = outline;
    }
    return n;
  });

  if (annotate) {
    // Labels go just above their block, or tuck into its corner when that's taken by another
    // block or label. One that fits neither is left out.
    const placed: Rect[] = [];
    const hits = (a: Rect, b: Rect) =>
      a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
    const byReading = blocks.map((_, i) => i).sort((a, b) => blocks[a].dom - blocks[b].dom);
    for (const i of byReading) {
      const block = blocks[i];
      if (!block.label) continue;
      const label = make(layer, {
        left: "0",
        top: "0",
        padding: "1px 4px",
        borderRadius: "3px",
        background: TONE.chip,
        font: "500 10px/13px ui-monospace, SFMono-Regular, Menlo, monospace",
        letterSpacing: "0.02em",
        whiteSpace: "nowrap",
        opacity: "0",
      });
      label.textContent = block.label;
      const size = { w: label.offsetWidth, h: label.offsetHeight };
      const { x, y } = block.rect;
      const others = blocks
        .filter((o) => o !== block && !o.el.contains(block.el) && !block.el.contains(o.el))
        .map((o) => o.rect);
      const spots = [
        { x, y: y - size.h - 3, inside: false },
        { x: x + 4, y: y + 4, inside: true },
      ];
      const spot = spots.find((s) => {
        const r = { ...s, ...size };
        return s.y >= 0 && !placed.some((p) => hits(p, r)) && !others.some((o) => hits(o, r));
      });
      if (!spot) {
        label.remove();
        continue;
      }
      placed.push({ x: spot.x, y: spot.y, ...size });
      label.style.left = px(spot.x);
      label.style.top = px(spot.y);
      nodes[i].label = label;
      nodes[i].inside = spot.inside;
    }
  }
  return nodes;
}

function show(n: Nodes) {
  put(n.group, "display", "");
  put(n.group, "opacity", "");
}

function hide(n: Nodes) {
  put(n.group, "display", "none");
}

function paintText(block: Extract<Block, { kind: "text" }>, n: Nodes, q: number) {
  if (q >= 1) return hide(n);
  show(n);
  paint(block.el, "opacity", "0");
  const head = q * (block.chars + BLUEPRINT.span);
  put(n.words, "display", q > 0 ? "" : "none");
  if (q > 0) {
    block.words.forEach((w, i) => {
      const node = n.wordNodes[i];
      const a = easeOut((head - w.c0) / BLUEPRINT.span);
      put(node, "opacity", fix(a * w.alpha));
      put(node, "filter", a > 0 && a < 1 ? `blur(${fix((1 - a) * BLUEPRINT.blur)}em)` : "none");
      if (w.count) {
        // Counts from when the print head reaches the number to the end of the block.
        const t = easeOut((head - w.c0) / Math.max(block.chars + BLUEPRINT.span - w.c0, 1));
        const text = countText(w.count, t);
        if (node.textContent !== text) node.textContent = text;
      }
    });
  }
  block.lines.forEach((line, i) => {
    const cut = Math.min(Math.max(xAt(block.words, line, head) - line.x, 0), line.w);
    const r = Math.min(line.bar.h / 2, 6);
    put(n.bars[i], "clip-path", cut > 0 ? `inset(0 0 0 ${fix(cut, 1)}px round ${fix(r, 1)}px)` : "none");
  });
  if (n.caret && block.lines.length) {
    const at = Math.min(head, block.chars);
    const line = block.lines.find((l) => at <= l.c1) ?? block.lines[block.lines.length - 1];
    put(n.caret, "transform", `translate(${fix(xAt(block.words, line, at), 1)}px, ${fix(line.y, 1)}px)`);
    put(n.caret, "height", px(line.h));
    put(n.caret, "opacity", fix(Math.min(smooth(q / 0.04), 1 - smooth((q - 0.86) / 0.14))));
  }
}

function paintImage(block: Extract<Block, { kind: "image" }>, n: Nodes, q: number) {
  if (q >= 1) return hide(n);
  show(n);
  const { saturate, zoom } = BLUEPRINT.develop;
  const d = easeOut((q - 0.15) / 0.85);
  const s = 1 + zoom * (1 - d);
  put(n.cross, "opacity", fix(1 - smooth(q / 0.3)));
  put(n.part, "opacity", fix(1 - smooth((q - 0.2) / 0.45)));
  paint(block.el, "opacity", fix(smooth((q - 0.15) / 0.3)));
  const blur = block.blur * (1 - d);
  const color = saturate + (1 - saturate) * d;
  paint(block.el, "filter", d > 0.999 ? "none" : `blur(${fix(blur, 2)}px) saturate(${fix(color)})`);
  // Scaled up inside a clip that's scaled down to match, so the photo settles inside a still frame.
  paint(block.el, "scale", scaleOf(s));
  const corners = block.radius.replace(PX, (_, v) => `${fix(parseFloat(v) / s, 2)}px`);
  paint(block.el, "clip-path", `inset(${fix((1 - 1 / s) * 50, 3)}% round ${corners})`);
}

function paintButton(block: Extract<Block, { kind: "button" }>, n: Nodes, q: number) {
  if (q >= 1) return hide(n);
  show(n);
  const { from } = BLUEPRINT.pop;
  paint(block.el, "opacity", fix(smooth(q / 0.25)));
  paint(block.el, "scale", scaleOf(from + (1 - from) * spring(q)));
  put(n.part, "opacity", fix(1 - smooth(q / 0.3)));
  put(n.part, "scale", fix(1 - 0.08 * smooth(q / 0.3), 4));
}

function paintBox(block: Extract<Block, { kind: "box" }>, n: Nodes, q: number) {
  if (q >= 1) return hide(n);
  show(n);
  paint(block.el, "background-color", mix(block.background, smooth(q / 0.7), block.surface));
  if (block.borderWidth > 0) {
    const drawn = q >= 0.7;
    put(n.draw, "stroke-dashoffset", fix(1 - easeInOut(q / 0.7)));
    put(n.draw, "opacity", drawn ? "0" : "1");
    paint(block.el, "border-color", drawn ? block.border : "transparent");
  }
  if (block.shadow !== "none") {
    paint(block.el, "box-shadow", fadeColors(block.shadow, smooth((q - 0.4) / 0.6)));
  }
  put(n.part, "opacity", fix(1 - smooth(q / 0.5)));
}

// Reduced motion: a short crossfade from skeleton to content, nothing else.
function paintReduced(block: Block, n: Nodes, q: number) {
  const f = smooth((q - 0.3) / 0.4);
  if (f >= 1) return hide(n);
  show(n);
  put(n.group, "opacity", fix(1 - f));
  put(n.words, "display", "none");
  put(n.caret, "opacity", "0");
  for (const bar of n.bars) put(bar, "clip-path", "none");
  put(n.cross, "opacity", "1");
  put(n.part, "opacity", "1");
  put(n.part, "scale", "1");
  put(n.draw, "opacity", "0");
  if (block.kind !== "box") return paint(block.el, "opacity", fix(f));
  paint(block.el, "background-color", mix(block.background, f, block.surface));
  if (block.borderWidth > 0) paint(block.el, "border-color", mix(block.border, f, "transparent"));
  if (block.shadow !== "none") paint(block.el, "box-shadow", fadeColors(block.shadow, f));
}

/* ── Engine ─────────────────────────────────────────────── */

type EngineOptions = {
  annotate: boolean;
  reduced: boolean;
  onBlockChange?: (element: HTMLElement, state: BlueprintState) => void;
  onSchedule?: (starts: number[]) => void;
};

/** Measures the page, keeps the skeleton layer in step with it, and paints any progress on demand. */
function createEngine() {
  let stage: HTMLElement | null = null;
  let layer: HTMLElement | null = null;
  let blocks: Block[] = [];
  let nodes: Nodes[] = [];
  let drawn: number[] = [];
  let progress = 0;
  let signature = "";
  let options: EngineOptions = { annotate: false, reduced: false };
  let frame = 0;
  let resize: ResizeObserver | null = null;

  function draw() {
    blocks.forEach((block, i) => {
      const q = clamp01((progress - block.start) / (block.end - block.start));
      if (drawn[i] === q) return;
      drawn[i] = q;
      const n = nodes[i];
      const state: BlueprintState = q <= 0 ? "wireframe" : q >= 1 ? "built" : "building";
      const prev = block.el.getAttribute("data-blueprint-state");
      if (prev !== state) {
        block.el.setAttribute("data-blueprint-state", state);
        if (prev !== null || state !== "wireframe") options.onBlockChange?.(block.el, state);
      }
      // Reduced motion steps it halfway through the block's fade, so effects built on it don't move.
      const step = options.reduced ? (smooth((q - 0.3) / 0.4) < 0.5 ? 0 : 1) : q;
      if (q < 1) paint(block.el, "--blueprint-progress", fix(step, 4));
      if (options.reduced) paintReduced(block, n, q);
      else if (block.kind === "text") paintText(block, n, q);
      else if (block.kind === "image") paintImage(block, n, q);
      else if (block.kind === "button") paintButton(block, n, q);
      else paintBox(block, n, q);
      if (q >= 1) unpaint(block.el);
      if (n.label) {
        const busy = options.reduced ? 0 : smooth(q / 0.06);
        const opacity = n.inside
          ? 0.75 * (1 - smooth(q / 0.1))
          : q >= 1
            ? 0
            : (0.6 + 0.4 * busy) * (1 - smooth((q - 0.7) / 0.3));
        put(n.label, "opacity", fix(opacity));
      }
    });
  }

  function measure() {
    if (!stage || !layer) return;
    for (const block of blocks) unpaint(block.el);
    // Read the page as it really is. Nothing paints before draw() hides the blocks again.
    stage.setAttribute("data-bp-ready", "");
    const next = readLayout(stage, layer);
    const key = JSON.stringify([options.annotate, next], (k, v) =>
      k === "el" ? undefined : typeof v === "number" ? Math.round(v * 2) / 2 : v,
    );
    if (key !== signature) {
      signature = key;
      for (const block of blocks) {
        if (!next.some((n) => n.el === block.el)) block.el.removeAttribute("data-blueprint-state");
      }
      blocks = next;
      nodes = build(layer, next, options.annotate, layer.clientWidth);
      resize?.disconnect();
      resize?.observe(stage);
      for (const block of next) resize?.observe(block.el);
      options.onSchedule?.(next.map((block) => block.start));
    }
    drawn = [];
    draw();
  }

  const schedule = () => {
    if (!frame) frame = requestAnimationFrame(() => ((frame = 0), measure()));
  };

  return {
    connect(nextStage: HTMLElement, nextLayer: HTMLElement) {
      stage = nextStage;
      layer = nextLayer;
      resize = new ResizeObserver(schedule);
      // Blueprint's own layer and controls change all the time; only the page counts.
      const ui = (node: Node) =>
        nextLayer.contains(node) ||
        (node instanceof Element ? node : node.parentElement)?.closest("[data-bp-ui]");
      const mutations = new MutationObserver((records) => {
        if (records.some((record) => !ui(record.target))) schedule();
      });
      mutations.observe(nextStage, {
        subtree: true,
        childList: true,
        characterData: true,
        attributes: true,
        attributeFilter: [
          "data-blueprint",
          "data-blueprint-order",
          "data-blueprint-label",
          "data-blueprint-count",
          "class",
          "src",
          "hidden",
        ],
      });
      const scheme = window.matchMedia("(prefers-color-scheme: dark)");
      scheme.addEventListener("change", schedule);
      document.fonts?.addEventListener("loadingdone", schedule);
      document.fonts?.ready.then(schedule);
      measure();
      return () => {
        cancelAnimationFrame(frame);
        frame = 0;
        resize?.disconnect();
        mutations.disconnect();
        scheme.removeEventListener("change", schedule);
        document.fonts?.removeEventListener("loadingdone", schedule);
        for (const block of blocks) {
          unpaint(block.el);
          block.el.removeAttribute("data-blueprint-state");
        }
        nextLayer.replaceChildren();
        nextStage.removeAttribute("data-bp-ready");
        blocks = [];
        nodes = [];
        signature = "";
        stage = layer = null;
      };
    },
    configure(next: EngineOptions) {
      const rebuild = next.annotate !== options.annotate;
      const repaint = next.reduced !== options.reduced;
      options = next;
      if (rebuild) {
        signature = "";
        measure();
      } else if (repaint) {
        for (const block of blocks) unpaint(block.el);
        drawn = [];
        draw();
      }
    },
    seek(next: number) {
      progress = clamp01(next);
      draw();
    },
  };
}

/* ── Blueprint ──────────────────────────────────────────── */

const subscribeNever = () => () => {};
/** False while rendering on the server and hydrating, true after. */
const useHydrated = () =>
  React.useSyncExternalStore(
    subscribeNever,
    () => true,
    () => false,
  );

export interface BlueprintProps extends React.HTMLAttributes<HTMLDivElement> {
  /** "auto" loops on its own, "scroll" follows the section into view, "manual" follows `progress`. */
  play?: "auto" | "scroll" | "manual";
  /** Progress in manual mode, from 0 (wireframe) to 1 (finished page). A MotionValue skips renders. */
  progress?: number | MotionValue<number>;
  /** Called as Blueprint moves the progress itself: the auto loop, scrolling, or the scrubber. */
  onProgressChange?: (progress: number) => void;
  /** Seconds the build takes in auto mode, or the length of each part of the loop. */
  duration?: number | Partial<BlueprintTiming>;
  /** Called each time the auto loop starts over on the wireframe, with how many loops have run. */
  onLoop?: (loop: number) => void;
  /** Called when a marked element starts building, finishes, or goes back to its wireframe. */
  onBlockChange?: (element: HTMLElement, state: BlueprintState) => void;
  /** Label each block with its spec while it builds: type size, image ratio, and so on. */
  annotate?: boolean;
  /** Show a scrubber under the stage to build and unbuild by hand. */
  scrubber?: boolean;
  /** Classes for the skeleton layer. Skeletons are drawn in its text color, so `text-*` recolors them. */
  skeletonClassName?: string;
}

/**
 * A finished layout that builds itself. Mark parts of your markup with
 * `data-blueprint` and each one starts as a skeleton measured from it, then
 * fills in, one block at a time, as progress runs from 0 to 1.
 */
export function Blueprint({
  play = "auto",
  progress,
  onProgressChange,
  duration,
  onLoop,
  onBlockChange,
  annotate = false,
  scrubber = false,
  skeletonClassName,
  className,
  children,
  onPointerEnter,
  onPointerLeave,
  onFocus,
  onBlur,
  ...props
}: BlueprintProps) {
  const reduced = useReducedMotion() ?? false;
  const hydrated = useHydrated();
  const stageRef = React.useRef<HTMLDivElement>(null);
  const layerRef = React.useRef<HTMLDivElement>(null);
  const [engine] = React.useState(createEngine);
  const [ticks, setTicks] = React.useState<number[]>([]);

  const internal = useMotionValue(
    play === "manual" && typeof progress === "number" ? clamp01(progress) : 0,
  );
  const external = play === "manual" && isMotionValue(progress) ? progress : null;
  const value = external ?? internal;

  const timing = timingOf(duration);
  const { build, hold, unbuild, rest } = timing;
  const latest = React.useRef({ onLoop, onProgressChange, timing });
  React.useLayoutEffect(() => {
    latest.current = { onLoop, onProgressChange, timing };
  });

  React.useLayoutEffect(() => {
    engine.configure({ annotate, reduced, onBlockChange, onSchedule: setTicks });
  }, [engine, annotate, reduced, onBlockChange]);

  React.useLayoutEffect(() => {
    const stage = stageRef.current;
    const layer = layerRef.current;
    if (!stage || !layer) return;
    return engine.connect(stage, layer);
  }, [engine]);

  React.useLayoutEffect(() => {
    engine.seek(value.get());
    return value.on("change", (p) => engine.seek(p));
  }, [engine, value]);

  React.useLayoutEffect(() => {
    if (play === "manual" && typeof progress === "number") internal.set(clamp01(progress));
  }, [play, progress, internal]);

  useMotionValueEvent(internal, "change", (p) => {
    if (play !== "manual") latest.current.onProgressChange?.(p);
  });

  /* Auto: one clock through rest, build, hold and unbuild. It only runs while nothing holds it. */
  const auto = play === "auto";
  const clock = React.useRef({ t: 0, loop: 0 });
  const [hovered, setHovered] = React.useState(false);
  const [focused, setFocused] = React.useState(false);
  const [seen, setSeen] = React.useState(true);
  const [dragging, setDragging] = React.useState(false);
  const [replaying, setReplaying] = React.useState(false);
  const running = auto && !reduced && !hovered && !focused && seen && !dragging;

  React.useEffect(() => {
    if (!auto || reduced) return;
    latest.current.onLoop?.(clock.current.loop);
  }, [auto, reduced]);

  React.useEffect(() => {
    if (!running) return;
    const length = rest + build + hold + unbuild;
    const timing = { rest, build, hold, unbuild };
    let raf = 0;
    let prev = performance.now();
    const tick = (now: number) => {
      const c = clock.current;
      c.t += Math.min(now - prev, 100) / 1000;
      prev = now;
      while (c.t >= length) {
        c.t -= length;
        c.loop += 1;
        latest.current.onLoop?.(c.loop);
      }
      internal.set(progressAt(c.t, timing));
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [running, rest, build, hold, unbuild, internal]);

  React.useEffect(() => {
    const stage = stageRef.current;
    if (!auto || !stage) return;
    let inView = true;
    const update = () => setSeen(inView && document.visibilityState === "visible");
    const observer = new IntersectionObserver(
      ([entry]) => {
        inView = entry.isIntersecting;
        update();
      },
      { threshold: 0.1 },
    );
    observer.observe(stage);
    document.addEventListener("visibilitychange", update);
    return () => {
      observer.disconnect();
      document.removeEventListener("visibilitychange", update);
    };
  }, [auto]);

  /* Reduced motion: auto mode rests on the finished page, with a replay. */
  const replay = React.useRef<AnimationPlaybackControls | null>(null);
  React.useEffect(() => {
    if (auto && reduced) internal.set(1);
  }, [auto, reduced, internal]);
  React.useEffect(() => () => replay.current?.stop(), []);

  const startReplay = () => {
    replay.current?.stop();
    setReplaying(true);
    internal.jump(0);
    replay.current = animate(internal, 1, {
      duration: latest.current.timing.build * BLUEPRINT.replay,
      ease: "linear",
      onComplete: () => setReplaying(false),
    });
  };

  /* Scrubbing: auto mode holds while dragging, then carries on building from there. */
  const scrub = (p: number) => {
    if (play === "manual") {
      if (external) external.set(p);
      onProgressChange?.(p);
      return;
    }
    replay.current?.stop();
    setReplaying(false);
    setDragging(true);
    internal.set(p);
  };
  const release = () => {
    if (auto) clock.current.t = latest.current.timing.rest + internal.get() * latest.current.timing.build;
    setDragging(false);
  };

  // The reduced-motion setting is known on the client before hydration, so the button waits for it.
  const showReplay = hydrated && auto && reduced && !replaying;

  const stage = (
    <div
      {...props}
      ref={stageRef}
      className={cn("relative isolate [&:not([data-bp-ready])_[data-blueprint]]:opacity-0", className)}
      onPointerEnter={(event) => {
        onPointerEnter?.(event);
        if (event.pointerType === "mouse") setHovered(true);
      }}
      onPointerLeave={(event) => {
        onPointerLeave?.(event);
        setHovered(false);
      }}
      onFocus={(event) => {
        onFocus?.(event);
        if (event.target.matches(":focus-visible")) setFocused(true);
      }}
      onBlur={(event) => {
        onBlur?.(event);
        if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocused(false);
      }}
    >
      {children}
      <div
        ref={layerRef}
        aria-hidden
        className={cn("pointer-events-none absolute inset-0 z-50 text-zinc-500 dark:text-zinc-400", skeletonClassName)}
      />
      {showReplay && !scrubber && (
        <button
          type="button"
          data-bp-ui=""
          onClick={startReplay}
          className="absolute right-3 bottom-3 z-50 inline-flex items-center gap-1.5 rounded-full border border-black/10 bg-white/90 px-3 py-1.5 text-xs font-medium text-zinc-900 shadow-sm backdrop-blur outline-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-2 dark:border-white/10 dark:bg-zinc-900/90 dark:text-zinc-100 dark:outline-white"
        >
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" className="size-3.5" aria-hidden>
            <path d="M2.5 8a5.5 5.5 0 1 0 1.6-3.9M2.5 2.5v3h3" strokeLinecap="round" strokeLinejoin="round" />
          </svg>
          Replay
        </button>
      )}
      {play === "scroll" && <ScrollDriver target={stageRef} value={internal} smooth={!reduced} />}
    </div>
  );

  if (!scrubber) return stage;
  return (
    <div className="flex flex-col">
      {stage}
      <Scrubber
        value={value}
        ticks={ticks}
        readOnly={play === "scroll"}
        onScrub={scrub}
        onRelease={release}
        onReplay={showReplay ? startReplay : undefined}
      />
    </div>
  );
}

/* ── Drivers ────────────────────────────────────────────── */

function ScrollDriver({
  target,
  value,
  smooth,
}: {
  target: React.RefObject<HTMLDivElement | null>;
  value: MotionValue<number>;
  smooth: boolean;
}) {
  // From the section's top entering the viewport to its middle reaching the middle.
  const { scrollYProgress } = useScroll({ target, offset: ["start end", "center center"] });
  const eased = useSpring(scrollYProgress, BLUEPRINT.scroll);
  const source = smooth ? eased : scrollYProgress;
  useMotionValueEvent(source, "change", (p) => value.set(p));
  React.useEffect(() => value.set(source.get()), [source, value]);
  return null;
}

// How far each key moves the scrubber. Home and End jump to the ends.
const SCRUB_KEYS: Record<string, number | undefined> = {
  ArrowRight: 0.02,
  ArrowUp: 0.02,
  ArrowLeft: -0.02,
  ArrowDown: -0.02,
  PageUp: 0.1,
  PageDown: -0.1,
};

function Scrubber({
  value,
  ticks,
  readOnly,
  onScrub,
  onRelease,
  onReplay,
}: {
  value: MotionValue<number>;
  ticks: number[];
  readOnly: boolean;
  onScrub: (progress: number) => void;
  onRelease: () => void;
  onReplay?: () => void;
}) {
  const trackRef = React.useRef<HTMLDivElement>(null);
  const fillRef = React.useRef<HTMLDivElement>(null);
  const thumbRef = React.useRef<HTMLDivElement>(null);
  const readoutRef = React.useRef<HTMLSpanElement>(null);
  const dragging = React.useRef(false);

  const show = React.useCallback((p: number) => {
    const percent = Math.round(p * 100);
    trackRef.current?.setAttribute("aria-valuenow", String(percent));
    if (fillRef.current) fillRef.current.style.transform = `scaleX(${p})`;
    if (thumbRef.current) thumbRef.current.style.left = `${p * 100}%`;
    if (readoutRef.current) readoutRef.current.textContent = `${percent}%`;
  }, []);
  React.useLayoutEffect(() => show(value.get()), [show, value]);
  useMotionValueEvent(value, "change", show);

  const at = (clientX: number) => {
    const r = trackRef.current?.getBoundingClientRect();
    return r && r.width ? clamp01((clientX - r.left) / r.width) : 0;
  };
  const end = () => {
    if (!dragging.current) return;
    dragging.current = false;
    onRelease();
  };

  return (
    <div data-bp-ui="" className="mt-3 flex items-center gap-3 text-zinc-900 dark:text-zinc-100">
      <div
        ref={trackRef}
        role="slider"
        tabIndex={readOnly ? -1 : 0}
        aria-label="Build progress"
        aria-valuemin={0}
        aria-valuemax={100}
        aria-valuenow={Math.round(value.get() * 100)}
        aria-readonly={readOnly || undefined}
        className={cn(
          "group relative h-6 flex-1 touch-none rounded-sm outline-current outline-offset-4 focus-visible:outline-2",
          !readOnly && "cursor-ew-resize",
        )}
        onPointerDown={(event) => {
          if (readOnly || event.button !== 0) return;
          event.currentTarget.setPointerCapture(event.pointerId);
          dragging.current = true;
          onScrub(at(event.clientX));
        }}
        onPointerMove={(event) => {
          if (dragging.current) onScrub(at(event.clientX));
        }}
        onPointerUp={end}
        onPointerCancel={end}
        onKeyDown={(event) => {
          if (readOnly) return;
          const delta = SCRUB_KEYS[event.key];
          const next =
            event.key === "Home"
              ? 0
              : event.key === "End"
                ? 1
                : delta === undefined
                  ? null
                  : clamp01(value.get() + delta);
          if (next === null) return;
          event.preventDefault();
          dragging.current = true;
          onScrub(next);
        }}
        onKeyUp={end}
        onBlur={end}
      >
        <div className="absolute inset-x-0 top-1/2 h-1 -translate-y-1/2 overflow-hidden rounded-full bg-current/12">
          <div ref={fillRef} className="h-full w-full origin-left scale-x-0 rounded-full bg-current/70" />
        </div>
        {ticks.map((t, i) => (
          <span
            key={i}
            className="absolute top-1/2 mt-1.5 h-1 w-px bg-current/25"
            style={{ left: `${t * 100}%` }}
          />
        ))}
        <div
          ref={thumbRef}
          className="absolute top-1/2 left-0 size-3 -translate-1/2 rounded-full bg-current shadow-sm ring-4 ring-current/10 transition-[scale] duration-150 group-active:scale-125"
        />
      </div>
      <span ref={readoutRef} className="w-9 text-right font-mono text-xs tabular-nums opacity-55">
        0%
      </span>
      {onReplay && (
        <button
          type="button"
          onClick={onReplay}
          className="-my-1 rounded-full border border-current/15 px-2.5 py-1 text-xs font-medium outline-current outline-offset-2 focus-visible:outline-2"
        >
          Replay
        </button>
      )}
    </div>
  );
}
```
