# Process steps

How it works section with a connecting line that draws on scroll and step cards that activate as you pass.

- Type: block
- Category: Sections
- Tier: Free
- Dependencies: lucide-react, motion

## Install

```bash
npx shadcn@latest add https://facadeui.com/r/process-steps.json
```

## Usage

```tsx
import { ProcessSteps } from "@/components/facade/process-steps";

export default function Page() {
  return <ProcessSteps />;
}
```

## Source

```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;
```
