Anatomy of Scroll Progress
How useScroll feeds a spring into scaleX (or a strokeDashoffset ring), and why reduced motion just stiffens the spring.
ScrollProgress doesn't poll scroll position on a timer. It reads
scrollYProgress from Framer Motion's useScroll, smooths it with
useSpring, and maps that single number onto either a pinned bar's
scaleX or a circle's strokeDashoffset.
A pinned bar that scales, not grows
The bar variant is a motion.div with role="progressbar",
origin-left, and style={{ scaleX: progress, height }}. Pinning is
fixed for the page, or sticky when a container ref is passed so the
indicator stays inside a scrollable panel. Width never changes —
scaleX keeps the paint cheap.
style={{ scaleX: progress }} · origin-left
- Bar
- scaleX(progress), origin-left
- Pin
- fixed · or sticky with container
- A11y
- role="progressbar"
const pinned = container ? "sticky" : "fixed";
<motion.div
role="progressbar"
aria-label="Scroll progress"
style={{ scaleX: progress, height }}
className={`${pinned} inset-x-0 top-0 left-0 z-sticky origin-left bg-primary`}
/>useScroll → useSpring → scaleX
useScroll yields a raw MotionValue that jumps with every wheel tick.
useSpring wraps it with { stiffness: 120, damping: 24, restDelta: 0.001 }
so the bar eases toward the true progress instead of stuttering. Under
prefers-reduced-motion, the spring stiffens to
{ stiffness: 1000, damping: 100 } — still a spring, just near-instant, so
the indicator keeps tracking without a visible lag.
scrollYProgress
useSpring(scrollYProgress, SPRING)
- Raw
- scrollYProgress — jumps with scroll
- Spring
- stiffness 120 · damping 24
- Reduced
- stiffness 1000 · damping 100
const { scrollYProgress } = useScroll(/* container | target | page */);
const progress = useSpring(
scrollYProgress,
reduceMotion ? { stiffness: 1000, damping: 100 } : SPRING,
);Circle: dashoffset, threshold, back-to-top
variant="circle" maps the same spring through
useTransform into strokeDashoffset = circumference * (1 − progress).
Visibility is gated by raw (unsprung) progress against showAfter
(default 0.05); AnimatePresence springs the button in and out
(stiffness: 520, damping: 32). Click finds the nearest
[data-scroll-container] or falls back to window.scrollTo.
- Ring
- strokeDashoffset from progress
- showAfter
- visible when raw progress > threshold
- Enter
- AnimatePresence spring 520/32
const dashoffset = useTransform(
progress,
(v) => circumference * (1 - Math.min(Math.max(v, 0), 1)),
);
React.useEffect(() => {
const update = (v: number) => setVisible(v > (showAfter ?? 0.05));
update(rawProgress.get());
return rawProgress.on("change", update);
}, [rawProgress, showAfter]);Why scaleX (and dashoffset) stay cheap
Neither variant animates layout. The bar never changes width; the ring
never redraws path geometry — only strokeDashoffset updates. The spring
runs on the MotionValue pipeline, not a React re-render per frame.
The result
Scroll this panel
Progress springs toward the scroll position — paragraph 1 of 12.
Progress springs toward the scroll position — paragraph 2 of 12.
Progress springs toward the scroll position — paragraph 3 of 12.
Progress springs toward the scroll position — paragraph 4 of 12.
Progress springs toward the scroll position — paragraph 5 of 12.
Progress springs toward the scroll position — paragraph 6 of 12.
Progress springs toward the scroll position — paragraph 7 of 12.
Progress springs toward the scroll position — paragraph 8 of 12.
Progress springs toward the scroll position — paragraph 9 of 12.
Progress springs toward the scroll position — paragraph 10 of 12.
Progress springs toward the scroll position — paragraph 11 of 12.
Progress springs toward the scroll position — paragraph 12 of 12.
One scroll signal, one spring, two skins — a bar that scales from the left, and a ring that fills until you send it home.