Browse the library
Blocks / Sections
Process steps
FreeHow it works section with a connecting line that draws on scroll and step cards that activate as you pass.
components/facade/process-steps.tsx
"use client";
import * as React from "react";
import {
motion,
useMotionValueEvent,
useReducedMotion,
useScroll,
useSpring,
useTransform,
} from "motion/react";
import { ArrowRight, Calendar, Check, CircleCheck, Phone, Wrench } from "lucide-react";
import { cn } from "@/lib/utils";
export interface ProcessStep {
title: string;
description: string;
/** Small meta label, e.g. "Takes 2 minutes". */
meta?: string;
icon?: React.ComponentType<{ className?: string }>;
}
export interface ProcessStepsProps {
eyebrow?: string;
title?: string;
description?: string;
steps?: ProcessStep[];
cta?: { label: string; href: string };
className?: string;
}
const EASE = [0.22, 1, 0.36, 1] as const;
const DEFAULT_STEPS: ProcessStep[] = [
{
title: "Tell us what's going on",
description: "Call, text or book online. Share a few photos and we'll confirm the issue before anyone rolls a truck.",
meta: "Takes 2 minutes",
icon: Phone,
},
{
title: "Get a flat-rate quote",
description: "You'll see the full price upfront, in writing. No overtime fees, no surprises on the invoice.",
meta: "Same day",
icon: Calendar,
},
{
title: "We do the work right",
description: "A licensed, background-checked tech arrives in a stocked van and treats your home like their own.",
meta: "Most jobs in one visit",
icon: Wrench,
},
{
title: "Backed for five years",
description: "Every repair is covered by our 5-year workmanship warranty. If it's not right, we come back free.",
meta: "In writing",
icon: CircleCheck,
},
];
export function ProcessSteps({
eyebrow = "How it works",
title = "From first call to fixed, in four simple steps",
description = "We've refined this process over 9,000 jobs in Denver. It's built around one idea: you should never have to chase us.",
steps = DEFAULT_STEPS,
cta = { label: "Start with step one", href: "#quote" },
className,
}: ProcessStepsProps) {
const reduce = useReducedMotion();
const listRef = React.useRef<HTMLOListElement>(null);
const { scrollYProgress } = useScroll({
target: listRef,
offset: ["start 75%", "end 55%"],
});
const progress = useSpring(scrollYProgress, { stiffness: 200, damping: 40, restDelta: 0.001 });
const scaleY = useTransform(progress, [0, 1], [0, 1]);
const [progressIndex, setProgressIndex] = React.useState(-1);
useMotionValueEvent(scrollYProgress, "change", (v) => {
const n = steps.length;
// Marker i sits at fraction i / (n - 1) of the line; activate slightly before the line reaches it.
const idx = v < 0.01 ? -1 : Math.min(n - 1, Math.floor(v * Math.max(n - 1, 1) + 0.25));
setProgressIndex(idx);
});
const active = reduce ? steps.length - 1 : progressIndex;
return (
<section className={cn("relative bg-background py-20 text-foreground sm:py-28", className)}>
<div className="mx-auto grid max-w-6xl gap-14 px-5 sm:px-8 lg:grid-cols-[0.9fr_1.1fr] lg:gap-20">
{/* Sticky intro */}
<div className="lg:sticky lg:top-24 lg:self-start">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-brand">{eyebrow}</p>
<h2 className="mt-3 font-display text-3xl font-semibold tracking-tight text-balance sm:text-5xl">
{title}
</h2>
<p className="mt-5 max-w-md text-lg leading-relaxed text-muted-foreground text-pretty">
{description}
</p>
{/* Mini progress indicator */}
<div className="mt-8 hidden items-center gap-2 lg:flex" aria-hidden>
{steps.map((_, i) => (
<span
key={i}
className={cn(
"h-1 rounded-full transition-all duration-500",
i <= active ? "w-10 bg-brand" : "w-5 bg-border",
)}
/>
))}
<span className="ml-2 font-mono text-xs text-muted-foreground">
{String(Math.max(active + 1, 0)).padStart(2, "0")}/{String(steps.length).padStart(2, "0")}
</span>
</div>
{cta && (
<a
href={cta.href}
className="group mt-8 inline-flex h-12 items-center gap-2 rounded-full bg-primary px-6 text-sm font-semibold text-primary-foreground transition-transform hover:-translate-y-px focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
{cta.label}
<ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" />
</a>
)}
</div>
{/* Steps */}
<ol ref={listRef} className="relative">
{/* Track */}
<span aria-hidden className="absolute bottom-8 left-[23px] top-8 w-px bg-border sm:left-[27px]" />
{/* Progress line */}
<motion.span
aria-hidden
className="absolute bottom-8 left-[23px] top-8 w-px origin-top bg-gradient-to-b from-brand via-brand to-brand/40 sm:left-[27px]"
style={{ scaleY: reduce ? 1 : scaleY }}
/>
{steps.map((step, i) => {
const isActive = i <= active;
const isCurrent = i === active;
const Icon = step.icon ?? Check;
return (
<li key={step.title} className="relative flex gap-5 pb-6 last:pb-0 sm:gap-7">
{/* Marker */}
<div className="relative z-10 shrink-0">
<motion.span
animate={{ scale: isCurrent && !reduce ? 1.06 : 1 }}
transition={{ type: "spring", stiffness: 400, damping: 30 }}
className={cn(
"flex size-12 items-center justify-center rounded-full border transition-colors duration-500 sm:size-14",
isActive
? "border-brand bg-brand text-brand-foreground shadow-[0_0_0_6px_color-mix(in_oklch,var(--brand)_15%,transparent)]"
: "border-border bg-card text-muted-foreground",
)}
>
<Icon className="size-5" />
</motion.span>
</div>
{/* Card */}
<motion.div
initial={{ opacity: 0, x: 16 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: "-80px 0px" }}
transition={{ duration: 0.7, ease: EASE }}
className={cn(
"relative flex-1 overflow-hidden rounded-2xl border p-6 transition-all duration-500 sm:p-7",
isActive
? "border-foreground/10 bg-card shadow-[0_20px_50px_-30px_color-mix(in_oklch,var(--foreground)_35%,transparent)]"
: "border-border bg-card/40",
)}
>
<span
aria-hidden
className={cn(
"pointer-events-none absolute -right-2 -top-6 select-none font-display text-[7rem] font-semibold leading-none tracking-tighter transition-colors duration-500",
isActive ? "text-brand/10" : "text-foreground/[0.04]",
)}
>
{String(i + 1).padStart(2, "0")}
</span>
<div className="relative">
<div className="flex flex-wrap items-center gap-3">
<span className="font-mono text-xs text-muted-foreground">
Step {String(i + 1).padStart(2, "0")}
</span>
{step.meta && (
<span
className={cn(
"rounded-full px-2 py-0.5 text-[11px] font-semibold transition-colors duration-500",
isActive ? "bg-brand/10 text-brand" : "bg-muted text-muted-foreground",
)}
>
{step.meta}
</span>
)}
</div>
<h3
className={cn(
"mt-3 font-display text-xl font-semibold tracking-tight transition-colors duration-500 sm:text-2xl",
!isActive && "text-foreground/60",
)}
>
{step.title}
</h3>
<p className="mt-2 max-w-md leading-relaxed text-muted-foreground text-pretty">
{step.description}
</p>
</div>
</motion.div>
</li>
);
})}
</ol>
</div>
</section>
);
}
export default ProcessSteps;app/page.tsx
import { ProcessSteps } from "@/components/facade/process-steps";
export default function Demo() {
return <ProcessSteps />;
}Installation
$
npx shadcn@latest add https://facadeui.com/r/process-steps.jsonUsage
import { ProcessSteps } from "@/components/facade/process-steps";
export default function Page() {
return <ProcessSteps />;
}Props
ProcessStepsProps is exported, so you can extend or wrap it. Every prop has a default; <ProcessSteps /> renders the example above.
| Prop | Type | Description |
|---|---|---|
| eyebrow | string | |
| title | string | |
| description | string | |
| steps | ProcessStep[] | |
| cta | { label: string; href: string } | |
| className | string |
Ask your agent
With the Facade MCP server connected, this prompt is enough:
Add the Facade process steps to this page and adapt the copy to our business.