Skip to content
Black FridayBlack Friday launch price: Lifetime $149 until 30 Nov. Regular price $299 from 1 Dec.ends in 15d 03:13:33Get Lifetime →
loopkit
freeNew154 lines · framer-motion

Scroll Progress Steps

A rail that always knows where you are. A full-width, pinned scroll section with a vertical rail that fills and lights up one step at a time, while a panel beside it cross-fades to match the active step.

Use it for
A how-it-works or onboarding flow explainer where each step deserves its own moment, without a long scroll of repeated layouts.
Themes
Light + dark
License
Commercial use
Live

A rail that always knows where you are

Step 1 / 4

Request comes in

A ticket, an email or an API call — all three land in the same queue.

tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useMotionValue, useReducedMotion, useScroll, useTransform, type MotionValue } from "framer-motion";
import { cn } from "@/lib/utils";

function useReducedMotionSafe() {
  const prefersReduced = useReducedMotion();
  const [mounted, setMounted] = React.useState(false);
  React.useEffect(() => setMounted(true), []);
  return mounted ? !!prefersReduced : false;
}

/**
 * Full-width scroll section: a vertical rail fills as you scroll, lighting
 * up one step at a time while a panel on the right cross-fades to match.
 */

export interface ProgressStep {
  title: string;
  body: string;
}

export interface ScrollProgressStepsProps {
  eyebrow?: string;
  title?: string;
  steps?: ProgressStep[];
  progress?: number;
  autoplay?: boolean;
  className?: string;
}

const DEFAULT_STEPS: ProgressStep[] = [
  { title: "Request comes in", body: "A ticket, an email or an API call — all three land in the same queue." },
  { title: "Context gets attached", body: "Account history, prior tickets and plan details join automatically." },
  { title: "A draft gets written", body: "A full reply is ready before anyone on the team opens the ticket." },
  { title: "A person signs off", body: "One click sends it, edits it, or hands it back for another pass." },
];

function useSectionProgress(ref: React.RefObject<HTMLElement | null>, progressProp: number | undefined, autoplay: boolean) {
  const reduce = useReducedMotionSafe();
  const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end end"] });
  const fixed = progressProp != null;
  const tall = !reduce && !fixed && !autoplay;
  const mv = useMotionValue(reduce ? 1 : progressProp ?? 0);

  React.useEffect(() => {
    if (reduce) mv.set(1);
    else if (fixed) mv.set(progressProp as number);
  }, [reduce, fixed, progressProp, mv]);

  React.useEffect(() => {
    if (reduce || fixed || !autoplay) return;
    const dur = 6000;
    const t0 = performance.now();
    let raf = 0;
    let visible = true;
    const el = ref.current;
    const io = el ? new IntersectionObserver(([e]) => (visible = e.isIntersecting)) : null;
    if (io && el) io.observe(el);
    const tick = (t: number) => {
      raf = requestAnimationFrame(tick);
      if (!visible || document.hidden) return;
      const elapsed = (t - t0) % (dur * 2);
      mv.set(elapsed < dur ? elapsed / dur : 2 - elapsed / dur);
    };
    raf = requestAnimationFrame(tick);
    return () => {
      cancelAnimationFrame(raf);
      io?.disconnect();
    };
  }, [reduce, fixed, autoplay, mv, ref]);

  React.useEffect(() => {
    if (reduce || fixed || autoplay) return;
    return scrollYProgress.on("change", (v) => mv.set(v));
  }, [reduce, fixed, autoplay, scrollYProgress, mv]);

  return { p: mv, reduce: !!reduce, tall };
}

function RailDot({ index, total, p, active }: { index: number; total: number; p: MotionValue<number>; active: boolean }) {
  const fill = useTransform(p, [index / total, (index + 0.6) / total], [0, 1], { clamp: true });
  return (
    <div className="relative flex flex-col items-center">
      <motion.span
        className={cn("grid size-9 shrink-0 place-items-center rounded-full font-mono text-[12px] font-bold transition-colors duration-300", active ? "bg-signal text-signal-foreground" : "bg-muted text-muted-foreground")}
      >
        {index + 1}
      </motion.span>
      {index < total - 1 && (
        <span className="mt-1 h-14 w-[3px] overflow-hidden rounded-full bg-border sm:h-20">
          <motion.span className="block w-full origin-top bg-signal" style={{ scaleY: fill, height: "100%" }} />
        </span>
      )}
    </div>
  );
}

export function ScrollProgressSteps({
  eyebrow = "No step skipped",
  title = "A rail that always knows where you are",
  steps = DEFAULT_STEPS,
  progress,
  autoplay = true,
  className,
}: ScrollProgressStepsProps) {
  const ref = React.useRef<HTMLElement>(null);
  const { p, reduce, tall } = useSectionProgress(ref, progress, autoplay);
  const [active, setActive] = React.useState(reduce ? steps.length - 1 : 0);

  React.useEffect(() => {
    return p.on("change", (v) => setActive(Math.min(steps.length - 1, Math.floor(v * steps.length))));
  }, [p, steps.length]);

  return (
    <section ref={ref} className={cn("relative bg-background", tall ? "h-[300vh]" : "py-20 sm:py-28", className)} aria-label={title}>
      <div className={cn("overflow-hidden", tall && "sticky top-0 h-dvh")}>
        <div className="mx-auto grid h-full w-full max-w-[64rem] items-center gap-10 px-5 py-10 sm:px-8 lg:grid-cols-[auto_1fr]">
          <div className="hidden sm:block">
            <p className="mb-6 font-mono text-[11px] uppercase tracking-[0.22em] text-muted-foreground">{eyebrow}</p>
            <div className="flex flex-col">
              {steps.map((s, i) => (
                <RailDot key={s.title} index={i} total={steps.length} p={p} active={active === i} />
              ))}
            </div>
          </div>
          <div>
            <h2 className="font-display text-[clamp(1.8rem,4vw,2.9rem)] font-extrabold leading-[1.05] tracking-[-0.03em]">{title}</h2>
            <div className="relative mt-6 min-h-[8rem]">
              <AnimatePresence mode="wait">
                <motion.div
                  key={active}
                  initial={reduce ? false : { opacity: 0, y: 14 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={reduce ? undefined : { opacity: 0, y: -14 }}
                  transition={{ duration: 0.35 }}
                  className="max-w-md rounded-3xl border border-border bg-card p-6"
                >
                  <span className="font-mono text-[11px] uppercase tracking-[0.18em] text-signal">Step {active + 1} / {steps.length}</span>
                  <h3 className="mt-2 font-display text-xl font-bold tracking-tight">{steps[active]?.title}</h3>
                  <p className="mt-2 text-sm leading-relaxed text-muted-foreground">{steps[active]?.body}</p>
                </motion.div>
              </AnimatePresence>
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

export default ScrollProgressSteps;

How it behaves

  • →Each rail segment's fill is its own slice of one shared, section-scoped scroll progress value, so segments light up strictly in order.
  • →The active step index is derived from the same progress value and swaps the description panel with a short cross-fade.
  • →At rest, the first step is lit and its panel is shown — a complete, sensible starting frame.
  • →autoplay drives the shared progress back and forth on a timer so the rail and panel keep moving without real scroll input.
  • →prefers-reduced-motion jumps straight to the final step with the rail fully lit, no animated fill.

Accessibility

  • ✓The active step's panel uses `AnimatePresence` with a short cross-fade; content is never removed and re-added in a way that steals focus.
  • ✓Step numbers are real text, not colour-only indicators of progress.
  • ✓Honours prefers-reduced-motion by skipping the fill animation and panel transitions.
  • ✓Honours prefers-reduced-motion.