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
No step skipped
1
2
3
4
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.