A roaster's site that smells like the shop
We rebuilt Ondas around the Tuesday roast: one weekly drop, tasting notes from the roaster, and a checkout that takes under a minute.
+48%
online orders
3.1×
repeat buyers
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.
Uses motion, clsx, tailwind-merge
Ceramics studio · Lisbon
Hand-thrown in Alfama, fired twice, and made to be used every day, not saved for guests.
Wheel classes every Saturday
"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>
);
}
Add the component on its own with the CLI, or copy the file in by hand.
The CLI writes components/ui/blueprint.tsx and installs motion, clsx and tailwind-merge. It needs the @keyframeui registry in components.json; add it once per project.
npx shadcn@latest add @keyframeui/blueprintnpm i motion clsx tailwind-mergeSkip this if you already use shadcn/ui.
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
"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>
);
}
The file imports cn from @/lib/utils. Change the path if your project uses a different alias.
Finished sections built with Blueprint. Each one installs whole, with the component included.
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.
Case study 07
Wireframe
Type
Imagery
Final
Scroll to build
We rebuilt Ondas around the Tuesday roast: one weekly drop, tasting notes from the roaster, and a checkout that takes under a minute.
+48%
online orders
3.1×
repeat buyers
"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'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>
);
}
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.
Revenue by region, last quarter
Built from 4 queries on orders and accounts
Q3 2026 · Jul 1 – Sep 30
Revenue
$4.82M
vs. Q2
+12.4%
Orders
18,240
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>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
childrenRequired | React.ReactNode | None | 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> | None | 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 | None | 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 | None | 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 | None | 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 | None | Classes for the stage, the positioned box your markup and the skeletons share. |
skeletonClassName | string | None | 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" | None | 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 | None | Attribute that replaces a block's annotation. An empty value hides it. |
data-blueprint-count | boolean | None | Attribute for text blocks: numbers in it count up from zero as they stream in, keeping their format ("$4.82M", "18,240", "+12.4%"). |
childrenRequiredTypeReact.ReactNode
Your markup. Every element marked with data-blueprint becomes a block; unmarked content shows as it is from the start.
playType"auto" | "scroll" | "manual"Default"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.
progressTypenumber | 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.
onProgressChangeType(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.
durationTypenumber | { build?: number; hold?: number; unbuild?: number; rest?: number }Default5
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).
onLoopType(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.
onBlockChangeType(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.
annotateTypebooleanDefaultfalse
Put a small spec label beside each block until it's built, read from its computed styles: "H1 · 56/60", "Image 16:9", "Button".
scrubberTypebooleanDefaultfalse
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.
classNameTypestring
Classes for the stage, the positioned box your markup and the skeletons share.
skeletonClassNameTypestring
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-blueprintType"" | "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-orderTypenumberDefault0
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-labelTypestring
Attribute that replaces a block's annotation. An empty value hides it.
data-blueprint-countTypeboolean
Attribute for text blocks: numbers in it count up from zero as they stream in, keeping their format ("$4.82M", "18,240", "+12.4%").