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






















