# Blueprint: Site Builder

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

A shadcn/ui block from the [@keyframeui](https://keyframeui.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/keyframeui/blueprint-demo).

- Page, with a live preview: https://shadcn.exchange/r/keyframeui/blueprint-demo
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/keyframeui/blueprint-demo-light-63a2bcf47ce5e413.png
- Registry item: https://keyframeui.com/r/blueprint-demo.json
- Categories: layout, scroll, motion
- Listed: 2026-10-14, source last changed 2026-10-15

## Install

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

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `https://keyframeui.com/r/blueprint.json`

## Notes from the registry

Live preview, examples and props: https://keyframeui.com/components/blueprint#blueprint-demo

## Files

### `registry/demos/blueprint-demo.tsx`

```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>
  );
}
```
