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.
@keyframeui
registry/demos/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'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>
);
}
Live preview, examples and props: https://keyframeui.com/components/blueprint#blueprint-process-demo























