AI Site Builder Demo with Self-Assembling Wireframe

A self-playing product demo for an AI builder. A sentence is typed into a prompt bar, grey wireframe blocks spring into a browser frame one at a time with an outline and a name tag, and then every bar fades into real copy and colour. The frame scales down cleanly on phones, where the navigation links and body text are dropped so nothing is cramped.

Code

import { useEffect, useRef, useState, type ReactNode } from "react";
import { motion, useInView, useReducedMotion } from "framer-motion";

/** The sentence that gets typed into the prompt bar. */
const PROMPT = "A warm site for a neighbourhood bakery with a menu and an order button";

/** Copy that replaces the grey wireframe bars once the style pass runs. */
const site = {
  name: "Tidewater Bakehouse",
  links: ["Menu", "Our story", "Visit"],
  eyebrow: "Open daily from 7am",
  headline: ["Fresh bread,", "every single morning"],
  body: ["Slow sourdough, laminated pastry and", "strong coffee, baked on Quay Street."],
  menu: [
    { name: "Country loaf", price: "$8.50", from: "#f6c177", to: "#d97706" },
    { name: "Almond croissant", price: "$4.75", from: "#fda4af", to: "#e11d48" },
    { name: "Flat white", price: "$4.00", from: "#d6bfa6", to: "#78350f" },
  ],
  visit: "14 Quay Street · Tue to Sun · 7am to 3pm",
};

/** When each block lands, in milliseconds from the start of a run. */
const blocks = [
  { id: "nav", label: "Navigation", at: 950 },
  { id: "hero", label: "Hero", at: 1250 },
  { id: "menu", label: "Menu cards", at: 1700 },
  { id: "visit", label: "Visit strip", at: 2100 },
];
const TYPE_MS = 13; // per typed character
const STYLE_AT = 2450; // wireframe turns into the styled page
const END = 3200;
const HOLD = 4200; // pause on the finished page before the next run
const TICK = 40;

/** A clock that runs while `playing`, loops after END + HOLD and restarts when `run` changes. */
function useClock(playing: boolean, run: number, still: boolean | null) {
  const [now, setNow] = useState(END);
  const t = useRef(END);
  useEffect(() => {
    if (still) return;
    t.current = 0;
    setNow(0);
  }, [run, still]);
  useEffect(() => {
    if (!playing || still) return;
    const id = window.setInterval(() => {
      if (document.hidden) return;
      t.current += TICK;
      if (t.current > END + HOLD) t.current = 0;
      setNow(Math.min(t.current, END));
    }, TICK);
    return () => window.clearInterval(id);
  }, [playing, still]);
  return now;
}

/** Text that starts life as a grey wireframe bar and fades into real copy. */
function Slot({ on, delay = 0, bar = "bg-stone-200", className = "", children }: { on: boolean; delay?: number; bar?: string; className?: string; children: ReactNode }) {
  const style = { transitionDelay: `${on ? delay : 0}ms` };
  return (
    <span className={`relative block w-fit ${className}`}>
      <span style={style} className={`block transition-opacity duration-500 motion-reduce:transition-none ${on ? "opacity-100" : "opacity-0"}`}>{children}</span>
      <span aria-hidden="true" style={style} className={`absolute inset-x-0 bottom-[22%] top-[22%] rounded-full transition-opacity duration-500 motion-reduce:transition-none ${bar} ${on ? "opacity-0" : "opacity-100"}`} />
    </span>
  );
}

/** One section of the page: springs into place with an outline and a name tag. */
function Block({ now, at, label, className = "", children }: { now: number; at: number; label: string; className?: string; children: ReactNode }) {
  const placed = now >= at;
  const placing = placed && now < at + 620;
  return (
    <div className="relative">
      {/* The empty slot this block will land in */}
      <span aria-hidden="true" className={`absolute inset-0 grid place-items-center rounded-xl border border-dashed px-1 text-center border-stone-300 font-sans text-[10px] font-medium uppercase tracking-wider text-stone-500 transition-opacity duration-200 ${placed ? "opacity-0" : "opacity-100"}`}>{label}</span>
      <motion.div
        initial={false}
        animate={placed ? { opacity: 1, y: 0, scale: 1 } : { opacity: 0, y: 22, scale: 0.95 }}
        transition={placed ? { type: "spring", stiffness: 320, damping: 24 } : { duration: 0 }}
        className={`relative ${className}`}
      >
      {children}
      <span aria-hidden="true" className={`pointer-events-none absolute inset-0 rounded-[inherit] ring-2 ring-inset ring-orange-500 transition-opacity duration-300 ${placing ? "opacity-100" : "opacity-0"}`} />
      <span aria-hidden="true" className={`pointer-events-none absolute -top-2 left-3 z-10 rounded-md bg-orange-500 px-1.5 py-0.5 font-sans text-[10px] font-semibold text-white shadow-sm transition-all duration-300 ${placing ? "translate-y-0 opacity-100" : "-translate-y-1 opacity-0"}`}>
        {label}
      </span>
      </motion.div>
    </div>
  );
}

export default function AiSiteBuilderDemoWithSelfAssemblingWireframe() {
  const reduce = useReducedMotion();
  const ref = useRef<HTMLDivElement>(null);
  const inView = useInView(ref, { amount: 0.25 });
  const [run, setRun] = useState(0);
  const now = useClock(inView, run, reduce);

  const typed = Math.min(PROMPT.length, Math.floor(now / TYPE_MS));
  const styled = now >= STYLE_AT;
  const done = now >= END;
  const current = [...blocks].reverse().find((b) => now >= b.at);
  const progress = Math.min(1, Math.max(0, (now - 700) / (END - 700)));
  const status = done ? "Ready to publish" : styled ? "Applying colours and copy" : current ? `Placing ${current.label.toLowerCase()}` : "Reading your prompt";

  // Surfaces that go from wireframe grey to the brand colour.
  const tint = "transition-colors duration-700 motion-reduce:transition-none";

  return (
    <section className="overflow-hidden bg-[#f7f1e7] px-4 py-16 sm:px-6 sm:py-20">
      <style>{`
        @keyframes ai-site-builder-demo-caret { 0%, 49% { opacity: 1; } 50%, 100% { opacity: 0; } }
        @keyframes ai-site-builder-demo-spin { to { transform: rotate(360deg); } }
        .ai-site-builder-demo-caret { animation: ai-site-builder-demo-caret 0.9s step-end infinite; }
        .ai-site-builder-demo-spin { animation: ai-site-builder-demo-spin 0.8s linear infinite; }
        @media (prefers-reduced-motion: reduce) { .ai-site-builder-demo-caret, .ai-site-builder-demo-spin { animation: none; } }
      `}</style>

      <div className="mx-auto max-w-4xl">
        <div className="mx-auto max-w-2xl text-center">
          <p className="inline-flex items-center gap-2 rounded-full bg-white px-3 py-1 text-xs font-semibold text-orange-700 shadow-sm ring-1 ring-stone-900/5">
            <span className="h-1.5 w-1.5 rounded-full bg-orange-500" aria-hidden="true" />
            Draft builder
          </p>
          <h2 className="mt-5 font-serif text-4xl tracking-tight text-stone-900 sm:text-5xl">One sentence in, a first draft out</h2>
          <p className="mt-4 text-lg leading-relaxed text-stone-600">Describe the business. The page is laid out block by block, then filled with colour and copy you can edit.</p>
        </div>

        <div ref={ref} className="relative mt-12">
          <div aria-hidden="true" className="absolute inset-x-10 -top-6 bottom-10 rounded-[48px] bg-gradient-to-br from-orange-300/50 via-amber-200/40 to-rose-300/50 blur-3xl" />

          {/* Browser frame */}
          <div className="relative overflow-hidden rounded-2xl bg-white shadow-2xl shadow-orange-900/15 ring-1 ring-stone-900/10" role="img" aria-label={`Preview of the generated website for ${site.name}`}>
            <div className="flex items-center gap-3 border-b border-stone-200 bg-stone-50 px-4 py-3">
              <div className="flex gap-1.5" aria-hidden="true">
                <span className="h-2.5 w-2.5 rounded-full bg-stone-300" />
                <span className="h-2.5 w-2.5 rounded-full bg-stone-300" />
                <span className="h-2.5 w-2.5 rounded-full bg-stone-300" />
              </div>
              <div className="min-w-0 flex-1 truncate rounded-md bg-white px-3 py-1 text-center font-mono text-[11px] text-stone-500 ring-1 ring-stone-200 sm:mx-auto sm:max-w-xs">tidewater-bakehouse.test</div>
              <div className="hidden items-center gap-2 text-xs font-medium text-stone-600 sm:flex">
                <span className={`h-2 w-2 rounded-full transition-colors duration-300 ${done ? "bg-emerald-500" : "bg-orange-500"}`} />
                <span className="w-44 truncate">{status}</span>
              </div>
            </div>
            <div className="h-0.5 bg-stone-100">
              <div className={`h-full origin-left transition-[transform,background-color] duration-200 ease-linear motion-reduce:transition-none ${done ? "bg-emerald-500" : "bg-orange-500"}`} style={{ transform: `scaleX(${progress})` }} />
            </div>

            {/* The page being built */}
            <div aria-hidden="true" className="bg-[radial-gradient(circle,rgba(120,113,108,0.22)_1px,transparent_1px)] bg-[length:14px_14px] px-3 pb-12 pt-3 sm:px-6 sm:pb-14 sm:pt-6">
              <div className="space-y-2 sm:space-y-3">
                <Block now={now} at={blocks[0].at} label={blocks[0].label} className="rounded-xl bg-white shadow-sm ring-1 ring-stone-200">
                  <div className="flex items-center justify-between gap-3 px-3 py-2.5 sm:px-5 sm:py-3">
                    <div className="flex items-center gap-2">
                      <span className={`h-5 w-5 rounded-full ${tint} ${styled ? "bg-orange-500" : "bg-stone-300"}`} />
                      <Slot on={styled} className="font-serif text-xs font-semibold text-stone-900 sm:text-sm">{site.name}</Slot>
                    </div>
                    <div className="hidden items-center gap-5 sm:flex">
                      {site.links.map((l, i) => (
                        <Slot key={l} on={styled} delay={60 + i * 40} className="text-xs text-stone-600">{l}</Slot>
                      ))}
                    </div>
                    <span className={`rounded-full px-3 py-1 text-[10px] font-semibold sm:text-xs ${tint} ${styled ? "bg-stone-900 text-white" : "bg-stone-200 text-transparent"}`}>Order</span>
                  </div>
                </Block>

                <Block now={now} at={blocks[1].at} label={blocks[1].label} className="rounded-xl bg-white shadow-sm ring-1 ring-stone-200">
                  <div className="grid grid-cols-[1.25fr_1fr] items-center gap-3 p-3 sm:gap-8 sm:p-7">
                    <div>
                      <Slot on={styled} delay={80} bar="bg-orange-200" className="text-[9px] font-semibold uppercase tracking-wider text-orange-700 sm:text-[11px]">{site.eyebrow}</Slot>
                      <div className="mt-1.5 sm:mt-3">
                        {site.headline.map((line, i) => (
                          <Slot key={line} on={styled} delay={140 + i * 70} bar="bg-stone-300" className="whitespace-nowrap font-serif text-base leading-tight text-stone-900 sm:text-4xl sm:leading-[1.1]">{line}</Slot>
                        ))}
                      </div>
                      <div className="mt-2 hidden sm:block">
                        {site.body.map((line, i) => (
                          <Slot key={line} on={styled} delay={260 + i * 50} className="whitespace-nowrap text-sm leading-6 text-stone-600">{line}</Slot>
                        ))}
                      </div>
                      <div className="mt-2.5 flex gap-2 sm:mt-5">
                        <span className={`whitespace-nowrap rounded-full px-2.5 py-1.5 text-[10px] font-semibold sm:px-4 sm:py-2 sm:text-xs ${tint} ${styled ? "bg-orange-500 text-white" : "bg-stone-300 text-transparent"}`}>Order ahead</span>
                        <span className={`whitespace-nowrap rounded-full px-2.5 py-1.5 text-[10px] font-semibold ring-1 ring-inset sm:px-4 sm:py-2 sm:text-xs ${tint} ${styled ? "text-stone-900 ring-stone-300" : "text-transparent ring-stone-200"}`}>See menu</span>
                      </div>
                    </div>
                    <div className="relative h-28 overflow-hidden rounded-lg bg-stone-200 sm:h-48">
                      <svg viewBox="0 0 200 140" preserveAspectRatio="none" className="absolute inset-0 h-full w-full text-stone-300">
                        <path d="M0 0 200 140M200 0 0 140" stroke="currentColor" strokeWidth="1.5" />
                      </svg>
                      <svg viewBox="0 0 200 140" preserveAspectRatio="xMidYMid slice" className={`absolute inset-0 h-full w-full transition-opacity duration-700 motion-reduce:transition-none ${styled ? "opacity-100" : "opacity-0"}`}>
                        <rect width="200" height="140" fill="#fde8cc" />
                        <circle cx="152" cy="38" r="48" fill="#fbbf77" opacity="0.65" />
                        <ellipse cx="100" cy="150" rx="150" ry="42" fill="#f0c592" />
                        <ellipse cx="96" cy="94" rx="64" ry="34" fill="#a8551f" />
                        <ellipse cx="96" cy="87" rx="58" ry="27" fill="#d98a45" />
                        <path d="M58 86q11-15 24-4M86 81q11-15 24-4M113 83q11-13 22-2" stroke="#fde8cc" strokeWidth="4" fill="none" strokeLinecap="round" />
                      </svg>
                    </div>
                  </div>
                </Block>

                <div className="grid grid-cols-3 gap-2 sm:gap-3">
                  {site.menu.map((m, i) => (
                    <Block key={m.name} now={now} at={blocks[2].at + i * 90} label={i === 0 ? blocks[2].label : m.name} className="rounded-xl bg-white shadow-sm ring-1 ring-stone-200">
                      <div className="p-2 sm:p-3">
                        <div className="relative h-12 overflow-hidden rounded-md bg-stone-200 sm:h-20">
                          <div className={`absolute inset-0 transition-opacity duration-700 motion-reduce:transition-none ${styled ? "opacity-100" : "opacity-0"}`} style={{ transitionDelay: styled ? `${200 + i * 80}ms` : "0ms", background: `radial-gradient(circle at 30% 30%, rgba(255,255,255,0.55), transparent 45%), linear-gradient(135deg, ${m.from}, ${m.to})` }} />
                        </div>
                        <Slot on={styled} delay={260 + i * 80} className="mt-2 max-w-full truncate text-[10px] font-semibold text-stone-900 sm:text-sm">{m.name}</Slot>
                        <Slot on={styled} delay={300 + i * 80} className="text-[10px] text-stone-600 sm:text-xs">{m.price}</Slot>
                      </div>
                    </Block>
                  ))}
                </div>

                <Block now={now} at={blocks[3].at} label={blocks[3].label} className={`rounded-xl shadow-sm ${tint} ${styled ? "bg-stone-900" : "bg-stone-300"}`}>
                  <div className="flex items-center justify-between gap-3 px-3 py-2.5 sm:px-5 sm:py-3.5">
                    <Slot on={styled} delay={380} bar="bg-stone-100/70" className="truncate text-[10px] text-stone-100 sm:text-xs">{site.visit}</Slot>
                    <span className={`shrink-0 rounded-full px-3 py-1 text-[10px] font-semibold sm:text-xs ${tint} ${styled ? "bg-orange-400 text-stone-900" : "bg-stone-100/70 text-transparent"}`}>Directions</span>
                  </div>
                </Block>
              </div>
            </div>
          </div>

          {/* Prompt bar, overlapping the bottom edge of the frame */}
          <div className="relative z-10 mx-auto -mt-9 flex max-w-2xl items-center gap-3 rounded-2xl bg-white p-2.5 pl-4 shadow-xl shadow-stone-900/15 ring-1 ring-stone-900/10 sm:w-[86%]">
            <svg viewBox="0 0 20 20" className="h-5 w-5 shrink-0 text-orange-500" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" aria-hidden="true">
              <path d="M3 5h14M3 10h9M3 15h6" />
            </svg>
            <p className="min-w-0 flex-1 text-left text-sm leading-snug text-stone-900 sm:text-[15px]" aria-label={`Prompt: ${PROMPT}`}>
              <span aria-hidden="true">
                {PROMPT.slice(0, typed)}
                {!done && <span className="ai-site-builder-demo-caret -my-0.5 ml-px inline-block h-[1.1em] w-0.5 translate-y-[0.18em] bg-orange-500" />}
                <span className="opacity-0">{PROMPT.slice(typed)}</span>
              </span>
            </p>
            <button
              type="button"
              onClick={() => setRun((r) => r + 1)}
              aria-label="Build the page again"
              className={`inline-flex h-11 shrink-0 items-center gap-2 rounded-xl px-3.5 text-sm font-semibold text-white transition-colors duration-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-orange-500 focus-visible:ring-offset-2 ${done ? "bg-stone-900 hover:bg-stone-700" : "bg-orange-600"}`}
            >
              {done ? (
                <svg viewBox="0 0 20 20" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                  <path d="M16 10a6 6 0 1 1-2-4.5M16 3.5v3h-3" />
                </svg>
              ) : (
                <span className="ai-site-builder-demo-spin h-4 w-4 rounded-full border-2 border-white/40 border-t-white" aria-hidden="true" />
              )}
              <span className="hidden sm:inline">{done ? "Build again" : "Building"}</span>
            </button>
          </div>
          <p className="mt-4 text-center text-xs text-stone-600" aria-live="polite">{done ? `${blocks.length} blocks placed and styled. Nothing here is final until you say so.` : status}</p>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/ai-site-builder-demo-with-self-assembling-wireframe.json

When to use it

How to customize it

Accessibility

Questions

How does the wireframe turn into the finished page?

Every piece of text is wrapped in a `Slot`. It renders the real text and a grey rounded bar on top of it, the same size. When `styled` becomes true the bar fades out and the text fades in, so the layout never shifts. Coloured surfaces use the same idea with `transition-colors`.

What drives the animation?

One small clock. `useClock` counts milliseconds on an interval and every part of the demo derives its state from that number: how many characters are typed, which blocks have landed and whether the style pass has run. That makes the timing easy to retune in one place.

Does it keep running when it is off screen?

No. `useInView` from framer-motion pauses the clock when less than a quarter of the frame is visible, and the interval also skips ticks while the browser tab is hidden.

Is this connected to a real AI service?

No. Nothing is generated and nothing is requested over the network. The prompt and the page are fixed demo content, which is what you want for a marketing section that must look the same every time.

Can I use it without React?

The HTML version gives you the finished, styled frame as static markup. The typing and assembly need a script, so for the animated version keep the React component.

Related: Glowing AI Prompt Box with Morphing Send Button, Split Hero with Self-Playing Chat Demo, Suggested Prompt Cards That Fill the Prompt Box.

More ai prompt and chat ui or all free UI components. Start for free to have a full site built around it.

More in AI Chat

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap