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-stepsControls
<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
- 1Add the VibeBox registry to
components.json:{ "registries": { "@vibebox": "https://motion.vibeboxph.com/r/{name}.json" } } - 2Add the block with the shadcn CLI:
npx shadcn@latest add @vibebox/how-it-works-steps - 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 filescomponents/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.
| Slot | Label | Type | Limit | Fact |
|---|---|---|---|---|
| eyebrow | Kicker | text | 32 chars | — |
| heading | Heading | text | 60 chars | — |
| lead | Intro | text | 160 chars | — |
| steps | Steps | list | up to 5 | — |
| steps[].title | Step | text | 40 chars | — |
| steps[].body | What happens | text | 140 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
rowgive narrow columns; prefertimeline.
Related

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 photo stays in view while the story, details and milestones scroll past it, each revealing as it arrives.
About block · Server

About Story
FreeThe business in its own words: a tall photo with a caption beside the story and a few honest details.
About block · Server