MynduiMyndui
0

Anatomy of Scroll Progress

How useScroll feeds a spring into scaleX (or a strokeDashoffset ring), and why reduced motion just stiffens the spring.

01/04A pinned bar that scales, not grows

style={{ scaleX: progress }} · origin-left

Bar
scaleX(progress), origin-left
Pin
fixed · or sticky with container
A11y
role="progressbar"
tsx
const pinned = container ? "sticky" : "fixed";<motion.divrole="progressbar"aria-label="Scroll progress"style={{ scaleX: progress, height }}className={`${pinned} inset-x-0 top-0 left-0 z-sticky origin-left bg-primary`}/>
01

A pinned bar that scales, not grows

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.

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.

02/04useScroll → useSpring → scaleX

scrollYProgress

useSpring(scrollYProgress, SPRING)

Raw
scrollYProgress — jumps with scroll
Spring
stiffness 120 · damping 24
Reduced
stiffness 1000 · damping 100
tsx
const { scrollYProgress } = useScroll(/* container | target | page */);const progress = useSpring(scrollYProgress,reduceMotion ? { stiffness: 1000, damping: 100 } : SPRING,);
02

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.

03/04Circle: dashoffset, threshold, back-to-top
Ring
strokeDashoffset from progress
showAfter
visible when raw progress > threshold
Enter
AnimatePresence spring 520/32
tsx
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]);
03

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.

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.

04/04Result

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.

04

The result

One scroll signal, one spring, two skins — a bar that scales from the left, and a ring that fills until you send it home.

Motion Score

Scroll ProgressSSCompositor-only
SscaleScale spring / press
SrotateRotation
SopacityFade / cross-fade
Sstroke-dashoffsetSVG stroke-dashoffset draw
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →