Skip to content
Motion
FreeIn reviewv1.0.0

Build Story

A process told by scrolling: steps pin beside an artboard of a website that builds itself, phase by phase.

npx shadcn@latest add @vibebox/build-story

Controls

78
<BuildStory />

About

"How it works" sections usually stack numbered cards. Build Story tells the process instead. On wide screens the section pins while the visitor scrolls; a step list with a spectrum rail fills step by step, and an artboard beside it shows a website moving from brief to plan, wireframe, build, review and launch. Clicking a step scrolls to it. On phones, where pinning is tiring, it becomes a stepper. Ported from the VibeBox home page; the steps are now props and the artboard is new. VibeBox's version drew its own plan, quote and photos; the library's is drawn in CSS with skeleton lines, so it suits any project. Pass your own art per step for anything else.

Use it when

  • A process has three to eight steps worth explaining.
  • The page has room for a tall pinned section.

Skip it when

  • The steps are trivial; a short list reads faster.
  • The page is already long with another pinned section.

Install

  1. 1Add the VibeBox registry to components.json:
    {
      "registries": {
        "@vibebox": "https://motion.vibeboxph.com/r/{name}.json"
      }
    }
  2. 2Add the component with the shadcn CLI:
    npx shadcn@latest add @vibebox/build-story
  3. 3Use it:
    import { BuildStory } from "@/components/vibebox/build-story";
    
    <BuildStory steps={steps} url="northline-dental.ph" caption="Demo site for illustration." />

Code

3 files
components/vibebox/build-story/build-story.client.tsx
/**
 * VibeBox Motion · Build Story
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
"use client";

import { AnimatePresence, LazyMotion, MotionConfig, domAnimation, m, useMotionValueEvent, useScroll, useTransform } from "motion/react";
import { useRef, useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { useMediaQuery, useReducedMotion } from "@/lib/vibebox/hooks";
import { ease } from "@/lib/vibebox/tokens/motion";
import { SiteArtboard, phaseFor } from "./site-artboard";

/* A process told by scrolling. On wide screens the section pins: the step list (with a spectrum
   rail that fills as you go) sits beside an artboard that changes with the active step, and a
   click on a step scrolls to it. On phones it becomes a stepper: step chips, the artboard, the
   step's text, and previous and next buttons. */

export type BuildStep = {
  /** Short number or code shown beside the title, e.g. "01". */
  index: string;
  title: string;
  /** A shorter title for the phone chips. */
  short?: string;
  description: string;
  /** What the step produces, shown as a short list. */
  outputs?: readonly string[];
};

export type BuildStoryProps = {
  steps: readonly BuildStep[];
  /** Draws the art for the active step; defaults to the built-in site artboard. */
  renderArt?: (stage: number) => ReactNode;
  /** Address for the default artboard. */
  url?: string;
  /** Small note under the art, e.g. that the site is a demo. */
  caption?: string;
  /** Scroll distance per step in the pinned layout, in viewport heights. */
  stepHeight?: number;
  /** When the pinned layout applies. */
  pinnedQuery?: string;
  className?: string;
};

export function BuildStory({
  steps,
  renderArt,
  url,
  caption,
  stepHeight = 78,
  pinnedQuery = "(min-width: 1040px)",
  className,
}: BuildStoryProps) {
  const pinned = useMediaQuery(pinnedQuery);
  const art = (stage: number) => renderArt?.(stage) ?? <SiteArtboard phase={phaseFor(stage, steps.length)} url={url} />;
  return (
    <LazyMotion features={domAnimation} strict>
      <MotionConfig reducedMotion="user">
        <div data-vb="build-story" data-layout={pinned ? "pinned" : "stepped"} className={className}>
          {pinned ? (
            <Pinned steps={steps} art={art} caption={caption} stepHeight={stepHeight} />
          ) : (
            <Stepped steps={steps} art={art} caption={caption} />
          )}
        </div>
      </MotionConfig>
    </LazyMotion>
  );
}

type LayoutProps = { steps: readonly BuildStep[]; art: (stage: number) => ReactNode; caption?: string };

function Pinned({ steps, art, caption, stepHeight }: LayoutProps & { stepHeight: number }) {
  const trackRef = useRef<HTMLDivElement>(null);
  const reduced = useReducedMotion();
  const [active, setActive] = useState(0);
  const { scrollYProgress } = useScroll({ target: trackRef, offset: ["start start", "end end"] });
  const railScale = useTransform(scrollYProgress, [0, 1], [0.04, 1]);
  useMotionValueEvent(scrollYProgress, "change", (value) => {
    const next = Math.min(steps.length - 1, Math.max(0, Math.floor(value * steps.length)));
    setActive((current) => (current === next ? current : next));
  });
  const jumpTo = (index: number) => {
    const track = trackRef.current;
    if (!track) return;
    const top = track.getBoundingClientRect().top + window.scrollY;
    const distance = track.offsetHeight - window.innerHeight;
    window.scrollTo({ top: top + ((index + 0.5) / steps.length) * distance, behavior: reduced ? "auto" : "smooth" });
  };
  return (
    <div ref={trackRef} className="relative" style={{ height: `${steps.length * stepHeight}vh` }}>
      <div className="sticky top-0 flex h-screen items-center">
        <div className="mx-auto grid w-full max-w-site grid-cols-[minmax(0,0.78fr)_minmax(0,1.22fr)] items-center gap-[clamp(32px,5vw,80px)] px-10 pt-16">
          <ol aria-label="Steps" className="relative m-0 grid list-none gap-1 py-0 pr-0 pl-7">
            <span aria-hidden="true" className="absolute top-1.5 bottom-1.5 left-0 w-0.5 overflow-hidden rounded-full bg-foreground/10">
              <m.span className="absolute inset-0 origin-top bg-[linear-gradient(180deg,var(--spectrum-1),var(--spectrum-2)_20%,var(--spectrum-3)_45%,var(--spectrum-4)_65%,var(--spectrum-5)_80%,var(--spectrum-7))]" style={{ scaleY: railScale }} />
            </span>
            {steps.map((step, index) => (
              <li key={step.index}>
                <button
                  type="button"
                  aria-current={index === active ? "step" : undefined}
                  onClick={() => jumpTo(index)}
                  className={cn(
                    "grid w-full cursor-pointer grid-cols-[34px_1fr] gap-x-1.5 gap-y-1 border-0 bg-transparent py-3 text-left transition-colors duration-200 ease-expo-out hover:text-foreground",
                    "rounded focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
                    index === active ? "text-foreground" : index < active ? "text-muted-foreground" : "text-subtle-foreground",
                  )}
                >
                  <span className="pt-1 font-mono text-xs">{step.index}</span>
                  <span className="text-[1.3rem] leading-tight font-semibold tracking-[-0.03em]">{step.title}</span>
                </button>
                {index === active && (
                  <m.div
                    initial={{ opacity: 0, y: 6 }}
                    animate={{ opacity: 1, y: 0 }}
                    transition={{ duration: 0.4, ease: ease.out }}
                    className="grid gap-3 pb-3 pl-10"
                  >
                    <p className="m-0 max-w-[380px] text-[0.98rem] leading-relaxed text-muted-foreground">{step.description}</p>
                    {step.outputs?.length ? (
                      <ul aria-label={`Step ${step.index} outputs`} className="m-0 grid list-none gap-1.5 p-0">
                        {step.outputs.map((output) => (
                          <li key={output} className="flex items-center gap-2.5 text-[0.9rem] text-foreground before:h-0.5 before:w-3 before:shrink-0 before:rounded-full before:bg-spectrum">
                            {output}
                          </li>
                        ))}
                      </ul>
                    ) : null}
                  </m.div>
                )}
              </li>
            ))}
          </ol>
          <div>
            {art(active)}
            {caption && <p className="mt-3.5 mb-0 text-right font-mono text-[0.74rem] text-subtle-foreground">{caption}</p>}
          </div>
        </div>
      </div>
    </div>
  );
}

function Stepped({ steps, art, caption }: LayoutProps) {
  const [active, setActive] = useState(0);
  const step = steps[active] ?? steps[0]!;
  return (
    <div className="mx-auto w-full max-w-site px-gutter">
      <div role="group" aria-label="Steps" className="mb-4.5 flex gap-2 overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
        {steps.map((item, index) => (
          <button
            key={item.index}
            type="button"
            aria-pressed={index === active}
            onClick={() => setActive(index)}
            className={cn(
              "min-h-11 shrink-0 cursor-pointer rounded-md border px-3.5 text-[0.88rem] font-semibold whitespace-nowrap focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
              index === active ? "border-foreground/50 bg-accent text-foreground" : "border-border bg-transparent text-muted-foreground",
            )}
          >
            {item.index} {item.short ?? item.title}
          </button>
        ))}
      </div>
      {art(active)}
      {caption && <p className="mt-2.5 mb-0 font-mono text-[0.72rem] text-subtle-foreground">{caption}</p>}
      <div aria-live="polite" className="mt-5.5 grid gap-3">
        <AnimatePresence mode="wait" initial={false}>
          <m.div key={step.index} initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} transition={{ duration: 0.3, ease: ease.out }} className="grid gap-3">
            <h3 className="m-0 text-[1.45rem] font-semibold tracking-[-0.03em]">{step.title}</h3>
            <p className="m-0 leading-relaxed text-muted-foreground">{step.description}</p>
          </m.div>
        </AnimatePresence>
        <div className="mt-1.5 flex gap-2">
          {[
            { label: "Previous step", disabled: active === 0, onClick: () => setActive((value) => Math.max(0, value - 1)) },
            { label: "Next step", disabled: active === steps.length - 1, onClick: () => setActive((value) => Math.min(steps.length - 1, value + 1)) },
          ].map((button) => (
            <button
              key={button.label}
              type="button"
              disabled={button.disabled}
              onClick={button.onClick}
              className="min-h-11 cursor-pointer rounded-md border border-border-strong bg-transparent px-4 text-[0.9rem] font-semibold text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-default disabled:opacity-40"
            >
              {button.label}
            </button>
          ))}
        </div>
      </div>
    </div>
  );
}
components/vibebox/build-story/index.tsx
/**
 * VibeBox Motion · Build Story
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
export { BuildStory, type BuildStep, type BuildStoryProps } from "./build-story.client";
export { ARTBOARD_PHASES, SiteArtboard, phaseFor, type ArtboardPhase, type SiteArtboardProps } from "./site-artboard";
components/vibebox/build-story/site-artboard.tsx
/**
 * VibeBox Motion · Build Story
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
import { cn } from "@/lib/utils";

/* A website that builds itself, one phase per step: a brief, a plan, a wireframe, the build, a
   review with pins, and the launch. Drawn in CSS with skeleton lines (no copy, no photos), so it
   illustrates any project. Server-rendered; phase changes cross-fade with transforms only. */

export const ARTBOARD_PHASES = ["brief", "plan", "wireframe", "build", "review", "launch"] as const;
export type ArtboardPhase = (typeof ARTBOARD_PHASES)[number];

/** Maps step `stage` of `count` steps onto the six phases. */
export function phaseFor(stage: number, count: number): number {
  if (count <= 1) return ARTBOARD_PHASES.length - 1;
  return Math.round((Math.min(stage, count - 1) / (count - 1)) * (ARTBOARD_PHASES.length - 1));
}

const CSS = `
[data-vb-sa] [data-sa-layer] { transition: opacity 420ms var(--ease-expo-out, cubic-bezier(0.16, 1, 0.3, 1)), transform 420ms var(--ease-expo-out, cubic-bezier(0.16, 1, 0.3, 1)); }
[data-vb-sa] [data-sa-layer="doc-brief"], [data-vb-sa] [data-sa-layer="doc-plan"], [data-vb-sa] [data-sa-layer="grid"], [data-vb-sa] [data-sa-layer="pins"], [data-vb-sa] [data-sa-layer="phone"] { opacity: 0; transform: translateY(10px); pointer-events: none; }
[data-vb-sa] [data-sa-layer="site"] { opacity: 0; transform: translateY(14px) scale(0.98); }
[data-vb-sa][data-phase="brief"] [data-sa-layer="doc-brief"],
[data-vb-sa][data-phase="plan"] [data-sa-layer="doc-plan"],
[data-vb-sa][data-phase="wireframe"] [data-sa-layer="grid"],
[data-vb-sa][data-phase="review"] [data-sa-layer="pins"],
[data-vb-sa]:is([data-phase="build"], [data-phase="review"], [data-phase="launch"]) [data-sa-layer="phone"] { opacity: 1; transform: none; }
[data-vb-sa]:is([data-phase="wireframe"], [data-phase="build"], [data-phase="review"], [data-phase="launch"]) [data-sa-layer="site"] { opacity: 1; transform: none; }
[data-vb-sa][data-phase="wireframe"] [data-sa-fill] { background: transparent !important; border: 1px dashed color-mix(in oklab, var(--muted-foreground) 55%, transparent); }
[data-vb-sa][data-phase="wireframe"] [data-sa-ink] { background: color-mix(in oklab, var(--muted-foreground) 45%, transparent) !important; }
[data-vb-sa] [data-sa-fill], [data-vb-sa] [data-sa-ink] { transition: background 420ms ease, border-color 420ms ease; }
[data-vb-sa] [data-sa-live] { opacity: 0; transform: scale(0.9); transition: opacity 300ms ease, transform 300ms var(--ease-expo-out, cubic-bezier(0.16, 1, 0.3, 1)); }
[data-vb-sa][data-phase="launch"] [data-sa-live] { opacity: 1; transform: none; }
@keyframes vb-sa-sweep { from { transform: translateX(-100%); } to { transform: translateX(100%); } }
@media (prefers-reduced-motion: no-preference) {
  [data-vb-sa][data-phase="launch"] [data-sa-sweep] { animation: vb-sa-sweep 1.4s var(--ease-expo-out, cubic-bezier(0.16, 1, 0.3, 1)) 1 both; }
}
@media (prefers-reduced-motion: reduce) {
  [data-vb-sa] [data-sa-layer], [data-vb-sa] [data-sa-live] { transition: none; }
}
`;

const bar = "block rounded-full";

function Doc({ phase }: { phase: "brief" | "plan" }) {
  return (
    <div data-sa-layer={`doc-${phase}`} className="absolute inset-0 grid place-items-center p-[6%]">
      <div className="w-[min(78%,420px)] rounded-lg border border-border bg-popover p-[5%] shadow-sm">
        <span className={cn(bar, "h-1.5 w-16 bg-subtle-foreground/50")} />
        <span className={cn(bar, "mt-3 h-3 w-3/5 bg-foreground/85")} />
        {phase === "brief" ? (
          <div className="mt-4 grid gap-2.5">
            {[0.55, 0.7, 0.45, 0.8].map((width, index) => (
              <div key={index} className="flex items-center justify-between gap-4 border-t border-border pt-2.5">
                <span className={cn(bar, "h-1.5 w-1/5 bg-muted-foreground/45")} />
                <span className={cn(bar, "h-2 bg-foreground/70")} style={{ width: `${width * 55}%` }} />
              </div>
            ))}
          </div>
        ) : (
          <div className="mt-4 grid gap-3">
            <div className="flex flex-wrap gap-1.5">
              {[0.3, 0.24, 0.34].map((width, index) => (
                <span key={index} className="h-5 rounded-full border border-border bg-muted" style={{ width: `${width * 100}%` }} />
              ))}
            </div>
            <div className="grid grid-cols-5 gap-1">
              {[1, 2, 3, 4, 5].map((stop) => (
                <span key={stop} className="h-1.5 rounded-full" style={{ background: `var(--spectrum-${stop})`, opacity: 0.75 }} />
              ))}
            </div>
            <div className="flex overflow-hidden rounded-md border border-border">
              <span className="h-6 w-2/5 bg-muted" />
              <span className="h-6 w-3/5" />
            </div>
          </div>
        )}
      </div>
    </div>
  );
}

export type SiteArtboardProps = {
  /** Phase index 0 to 5 (brief, plan, wireframe, build, review, launch). */
  phase: number;
  /** The address shown once the site is built ("staging." is added during build and review). */
  url?: string;
  /** Labels in the address bar for the first three phases. */
  docLabels?: readonly [string, string, string];
  className?: string;
};

export function SiteArtboard({ phase, url = "yourbusiness.com", docLabels = ["project-brief", "plan-and-quote", "sitemap"], className }: SiteArtboardProps) {
  const index = Math.max(0, Math.min(ARTBOARD_PHASES.length - 1, Math.round(phase)));
  const name = ARTBOARD_PHASES[index]!;
  const address = index < 3 ? docLabels[index] : index < 5 ? `staging.${url}` : url;
  return (
    <div
      data-vb-sa=""
      data-phase={name}
      role="img"
      aria-label={`Illustration: a website at the ${name} phase`}
      className={cn("relative w-full overflow-hidden rounded-xl border border-border-strong bg-card shadow-[0_50px_100px_-40px_rgba(0,0,0,0.6)]", className)}
    >
      <style href="vb-site-artboard" precedence="vb">
        {CSS}
      </style>
      <div className="relative grid h-[38px] grid-cols-[70px_1fr_70px] items-center overflow-hidden border-b border-border bg-muted px-3">
        <span className="flex gap-1.5" aria-hidden="true">
          <i className="size-2 rounded-full bg-foreground/20" />
          <i className="size-2 rounded-full bg-foreground/20" />
          <i className="size-2 rounded-full bg-foreground/20" />
        </span>
        <span className="inline-flex h-6 items-center justify-self-center rounded-md bg-foreground/5 px-3 font-mono text-[11px] text-muted-foreground">{address}</span>
        <span data-sa-live="" className="inline-flex items-center gap-1.5 justify-self-end font-mono text-[11px] text-success">
          <i className="size-1.5 rounded-full bg-success" />
          Live
        </span>
        <span data-sa-sweep="" aria-hidden="true" className="pointer-events-none absolute inset-0 -translate-x-full bg-[linear-gradient(90deg,transparent,color-mix(in_oklab,var(--spectrum-3)_30%,transparent),transparent)]" />
      </div>
      <div aria-hidden="true" className="@container relative aspect-[16/10] bg-background">
        <Doc phase="brief" />
        <Doc phase="plan" />
        <div data-sa-layer="grid" className="absolute inset-0 grid grid-cols-12 gap-[2%] px-[5%]">
          {Array.from({ length: 12 }, (_, column) => (
            <i key={column} className="h-full bg-[color-mix(in_oklab,var(--spectrum-2)_7%,transparent)]" />
          ))}
        </div>
        <div data-sa-layer="site" className="absolute inset-0 grid grid-rows-[auto_1fr_auto] gap-[5%] p-[5%]">
          <div className="flex items-center justify-between">
            <span className="flex items-center gap-2">
              <i data-sa-fill="" className="size-3 rounded-sm bg-spectrum-3" />
              <span data-sa-ink="" className={cn(bar, "h-2 w-16 bg-foreground/85")} />
            </span>
            <span className="hidden gap-3 @[420px]:flex">
              {[0, 1, 2].map((item) => (
                <span key={item} data-sa-ink="" className={cn(bar, "h-1.5 w-8 bg-muted-foreground/50")} />
              ))}
            </span>
            <span data-sa-fill="" className="h-5 w-12 rounded-md bg-primary" />
          </div>
          <div className="grid grid-cols-[1.1fr_0.9fr] items-center gap-[6%]">
            <div className="grid gap-2">
              <span data-sa-ink="" className={cn(bar, "h-3.5 w-11/12 bg-foreground/90")} />
              <span data-sa-ink="" className={cn(bar, "h-3.5 w-3/5 bg-foreground/90")} />
              <span data-sa-ink="" className={cn(bar, "mt-1.5 h-1.5 w-4/5 bg-muted-foreground/55")} />
              <span data-sa-ink="" className={cn(bar, "h-1.5 w-3/5 bg-muted-foreground/55")} />
              <span className="mt-2 flex gap-2">
                <span data-sa-fill="" className="h-6 w-20 rounded-md bg-primary" />
                <span data-sa-fill="" className="h-6 w-20 rounded-md border border-border-strong bg-muted" />
              </span>
            </div>
            <div
              data-sa-fill=""
              className="aspect-[4/3] rounded-lg"
              style={{ background: "linear-gradient(135deg, var(--spectrum-1), var(--spectrum-3) 55%, var(--spectrum-5))" }}
            />
          </div>
          <div className="grid grid-cols-3 gap-[3%]">
            {[1, 4, 6].map((stop) => (
              <div key={stop} data-sa-fill="" className="grid gap-1.5 rounded-md border border-border bg-card p-[8%]">
                <i data-sa-fill="" className="size-3 rounded-sm" style={{ background: `var(--spectrum-${stop})` }} />
                <span data-sa-ink="" className={cn(bar, "h-1.5 w-3/5 bg-foreground/80")} />
                <span data-sa-ink="" className={cn(bar, "h-1 w-4/5 bg-muted-foreground/45")} />
              </div>
            ))}
          </div>
        </div>
        <div data-sa-layer="pins" className="absolute inset-0">
          {[
            { top: "18%", left: "38%" },
            { top: "44%", left: "62%" },
            { top: "74%", left: "22%" },
          ].map((pin, number) => (
            <span
              key={number}
              className="absolute grid size-6 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-spectrum-4 font-mono text-[11px] font-semibold text-white shadow-[0_0_0_4px_color-mix(in_oklab,var(--spectrum-4)_25%,transparent)]"
              style={pin}
            >
              {number + 1}
            </span>
          ))}
        </div>
        <div data-sa-layer="phone" className="absolute right-[4%] bottom-[6%] w-[16%] rounded-[12px] bg-[#11141c] p-[0.6%] shadow-md">
          <div className="grid aspect-[9/17] content-start gap-1.5 rounded-[9px] bg-background p-[12%]">
            <span className={cn(bar, "mt-[18%] h-1.5 w-11/12 bg-foreground/85")} />
            <span className={cn(bar, "h-1.5 w-3/5 bg-foreground/85")} />
            <span className="mt-1 aspect-[4/3] rounded-sm" style={{ background: "linear-gradient(135deg, var(--spectrum-1), var(--spectrum-5))" }} />
            <span className="h-2.5 w-3/5 rounded-sm bg-primary" />
          </div>
        </div>
      </div>
    </div>
  );
}

Props

PropTypeDefaultDescription
steps *readonly BuildStep[]—Each step's index, title, short title, description and outputs.
renderArt(stage: number) => ReactNode—Art for the active step; defaults to the built-in site artboard.
urlstring"yourbusiness.com"Address for the default artboard.
captionstring—Small note under the art.
stepHeightnumber78Scroll distance per step in viewport heights.
pinnedQuerystring"(min-width: 1040px)"When the pinned layout applies.
classNamestring—Merged with cn().

Accessibility and motion

Keyboard
Steps are buttons (Tab, Enter): in the pinned layout they scroll to the step; on phones they switch it. Previous and next buttons on phones.
Screen readers
An ordered list with aria-current="step"; phone chips use aria-pressed and the step text is a polite live region; the artboard is a labelled image.
Touch
Phone chips and buttons are 44 px tall; the stepper replaces pinning on small screens.
Reduced motion
Steps and artboard phases switch without movement; jumps are instant.

Edge cases and limits

  • More than eight steps make the pinned track very long; split the process.
  • Custom renderArt should keep a stable size across stages to avoid layout shift.
  • A thin spectrum scroll rail with a readout naming the current section

    The page scrollbar redrawn: a spectrum thumb, section ticks, and a readout that names where you are.

    Scroll · Motion

↑ ↓ to move↵ to openesc to close