Skip to content
Motion
FreeIn reviewv1.0.0

Steps

Three to five steps from first contact to done, as a numbered row or a timeline beside the heading.

npx shadcn@latest add @vibebox/how-it-works-steps

Controls

Variant
<HowItWorksSteps />

About

Rebuilds the site kit's process section. It is the free default for how-it-works in every template. how-it-works-build-story (Pro) is the animated alternative.

Use it when

  • Visitors need to know what happens after they get in touch.
  • A booking, an order or a project has clear stages.

Skip it when

  • The process is one step: say it in the call to action instead.

Install

  1. 1Add the VibeBox registry to components.json:
    {
      "registries": {
        "@vibebox": "https://motion.vibeboxph.com/r/{name}.json"
      }
    }
  2. 2Add the block with the shadcn CLI:
    npx shadcn@latest add @vibebox/how-it-works-steps
  3. 3Use it:
    import { HowItWorksSteps } from "@/components/vibebox/how-it-works-steps";
    import { home } from "@/content/pages/home";
    
    <HowItWorksSteps content={home.howItWorks} variant="row" id="process" />

Code

2 files
components/vibebox/how-it-works-steps/index.tsx
/**
 * VibeBox Motion · Steps
 * 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 type { CSSProperties } from "react";
import { Container, headingId, REVEAL, Section, SectionHead, SlotText, type BlockProps } from "@/lib/vibebox/block-kit";
import { cn } from "@/lib/utils";
import type { Content, Variant } from "./schema";

/*
 * Three to five steps from first contact to done: a numbered row joined by a hairline, or a
 * vertical timeline beside the heading. Steps reveal one after another as they scroll in.
 */

/* The journey: a track that fills with the brand colour as the steps scroll through, each number
   lighting as the fill reaches it (CSS scroll-driven, a named view timeline on the list). Down the
   left on phones, across on wide screens. Complete and lit without scroll timelines or with motion
   off. */
const JOURNEY_STYLES = `
[data-vb="how-it-works-steps"] [data-vb-journey] { view-timeline: --vb-journey block; }
[data-vb="how-it-works-steps"] [data-vb-journey-fill] { transform-origin: 0 0; }
[data-vb="how-it-works-steps"] [data-vb-journey-on] { opacity: 1; }
@keyframes vb-journey-y { from { transform: scaleY(0); } to { transform: scaleY(1); } }
@keyframes vb-journey-x { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@keyframes vb-journey-on { from { opacity: 0; transform: scale(0.6); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    :root:not([data-motion="0"]) [data-vb="how-it-works-steps"] [data-vb-journey-fill] { animation: vb-journey-y linear both; animation-timeline: --vb-journey; animation-range: cover 18% cover 58%; }
    :root:not([data-motion="0"]) [data-vb="how-it-works-steps"] [data-vb-journey-on] {
      animation: vb-journey-on linear both; animation-timeline: --vb-journey;
      animation-range: cover calc(18% + 40% * var(--i) / var(--vb-last)) cover calc(22% + 40% * var(--i) / var(--vb-last));
    }
  }
}
@media (min-width: 64rem) and (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    :root:not([data-motion="0"]) [data-vb="how-it-works-steps"] [data-vb-journey-fill] { animation-name: vb-journey-x; }
  }
}
`;

export type HowItWorksStepsProps = BlockProps<Content, Variant>;

export function HowItWorksSteps({ content, variant = "row", id, tone, className }: HowItWorksStepsProps) {
  const timeline = variant === "timeline";
  if (variant === "journey") return <Journey content={content} id={id} tone={tone} className={className} />;
  return (
    <Section slug="how-it-works-steps" variant={variant} id={id} tone={tone} labelledBy={headingId(id)} className={className}>
      <Container className={cn("grid gap-[clamp(32px,4vw,56px)]", timeline && "lg:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)] lg:gap-20")}>
        <SectionHead eyebrow={content.eyebrow} heading={content.heading} lead={content.lead} id={id} className={cn(timeline && "lg:sticky lg:top-24 lg:self-start")} />
        {timeline ? (
          <ol className="relative grid gap-10 before:absolute before:top-2 before:bottom-2 before:left-[19px] before:w-px before:bg-border-strong">
            {content.steps.map((step, index) => (
              <li key={index} className={cn("relative grid grid-cols-[40px_minmax(0,1fr)] gap-x-5 gap-y-2", REVEAL)}>
                <span
                  aria-hidden="true"
                  className="row-span-2 inline-grid size-10 place-items-center rounded-full bg-background font-mono text-small font-semibold ring-1 ring-border-strong"
                >
                  {index + 1}
                </span>
                <SlotText as="h3" value={step.title} path={`steps.${index}.title`} className="pt-1.5 text-h3 font-semibold tracking-tight" />
                <SlotText as="p" value={step.body} path={`steps.${index}.body`} className="max-w-[52ch] text-pretty text-muted-foreground" />
              </li>
            ))}
          </ol>
        ) : (
          <ol
            className="grid gap-8 sm:grid-cols-2 lg:grid-cols-[repeat(var(--vb-steps),minmax(0,1fr))] lg:gap-6"
            style={{ "--vb-steps": content.steps.length } as CSSProperties}
          >
            {content.steps.map((step, index) => (
              <li key={index} className={cn("relative grid content-start gap-3", REVEAL)}>
                <div className="flex items-center gap-3">
                  <span aria-hidden="true" className="inline-grid size-10 shrink-0 place-items-center rounded-full bg-primary font-mono text-small font-semibold text-primary-foreground">
                    {index + 1}
                  </span>
                  {index < content.steps.length - 1 && <span aria-hidden="true" className="hidden h-px flex-1 bg-[linear-gradient(90deg,var(--border-strong),transparent)] lg:block" />}
                </div>
                <SlotText as="h3" value={step.title} path={`steps.${index}.title`} className="text-h3 font-semibold tracking-tight" />
                <SlotText as="p" value={step.body} path={`steps.${index}.body`} className="text-pretty text-muted-foreground" />
              </li>
            ))}
          </ol>
        )}
      </Container>
    </Section>
  );
}

function Journey({ content, id, tone, className }: Omit<HowItWorksStepsProps, "variant">) {
  const count = content.steps.length;
  return (
    <Section slug="how-it-works-steps" variant="journey" id={id} tone={tone} labelledBy={headingId(id)} className={className}>
      <style href="vb-how-it-works-journey" precedence="vb">
        {JOURNEY_STYLES}
      </style>
      <Container className="grid gap-[clamp(32px,4vw,56px)]">
        <SectionHead eyebrow={content.eyebrow} heading={content.heading} lead={content.lead} id={id} />
        <div data-vb-journey="" className="relative" style={{ "--vb-steps": count, "--vb-last": Math.max(1, count - 1) } as CSSProperties}>
          <div aria-hidden="true" className="absolute top-[27px] bottom-[27px] left-[26px] w-0.5 overflow-hidden rounded-full bg-border lg:top-[26px] lg:right-[27px] lg:bottom-auto lg:left-[27px] lg:h-0.5 lg:w-auto">
            <div data-vb-journey-fill="" className="size-full bg-primary" />
          </div>
          <ol className="relative grid gap-[34px] lg:grid-cols-[repeat(var(--vb-steps),minmax(0,1fr))] lg:gap-[clamp(24px,4vw,56px)]">
            {content.steps.map((step, index) => (
              <li key={index} className="relative grid content-start gap-2.5 pl-[78px] lg:pt-[78px] lg:pl-0">
                <span
                  aria-hidden="true"
                  className="absolute top-[-4px] left-0 grid size-[54px] place-items-center overflow-hidden rounded-full bg-background font-serif text-[1.35rem] text-foreground italic ring-[1.5px] ring-border-strong ring-inset lg:top-0"
                >
                  {index + 1}
                  {/* Lit: the contrast-checked primary pair, with its own copy of the number. */}
                  <span data-vb-journey-on="" className="absolute inset-0 grid place-items-center rounded-full bg-primary text-primary-foreground" style={{ "--i": index } as CSSProperties}>
                    {index + 1}
                  </span>
                </span>
                <SlotText as="h3" value={step.title} path={`steps.${index}.title`} className="text-h3 font-semibold tracking-tight" />
                <SlotText as="p" value={step.body} path={`steps.${index}.body`} className="max-w-[34ch] text-pretty text-muted-foreground" />
              </li>
            ))}
          </ol>
        </div>
      </Container>
    </Section>
  );
}
components/vibebox/how-it-works-steps/schema.ts
/**
 * VibeBox Motion · Steps
 * 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 { sections, type z } from "@/lib/vibebox/content";

export const variants = ["row", "timeline", "journey"] as const;

export type Variant = (typeof variants)[number];

export const content = sections["how-it-works"];

export type Content = z.infer<typeof content>;

Content

Typed slots from schema.ts. Facts (hours, prices, quotes, names) come only from the business; until then they render as fill-ins.

SlotLabelTypeLimitFact
eyebrowKickertext32 chars—
headingHeadingtext60 chars—
leadIntrotext160 chars—
stepsStepslistup to 5—
steps[].titleSteptext40 chars—
steps[].bodyWhat happenstext140 chars—

Variants

  • Rowdefault

    row

    Numbered steps in a row joined by a fading hairline; two columns on tablets.

  • Timeline

    timeline

    A vertical timeline beside a sticky heading on desktop.

  • Journey

    journey

    A track that fills with the brand colour as the steps scroll through, lighting each serif number as it arrives; down the left on phones, across on desktop.

Accessibility and motion

Keyboard
Nothing to focus.
Screen readers
A heading and an ordered list of steps, each a heading and a paragraph; numbers are hidden (the list is ordered).
Touch
Nothing to touch.
Reduced motion
Steps appear without their reveal; the journey's track is full and every number lit.

Edge cases and limits

  • Five long steps in row give narrow columns; prefer timeline.
  • Process steps beside a site artboard assembling itself

    The process as a build story: an artboard of the site assembles itself step by step as you scroll.

    How it works block · Server

  • A tall window scene beside a bakery's story and a timeline of milestones

    A tall photo stays in view while the story, details and milestones scroll past it, each revealing as it arrives.

    About block · Server

  • A tall horizon scene beside the story of a family dental clinic and three details

    The business in its own words: a tall photo with a caption beside the story and a few honest details.

    About block · Server

↑ ↓ to move↵ to openesc to close