GodUIGodUI
91

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.

Anatomypinned bar · origin-left · scaleX

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.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.

The springuseScroll → 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,
);

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.

Circle variantdashoffset · showAfter · 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]);

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

Resultthe real component — scroll the panel

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.

On this page

Built with GodUI

Beautifully crafted motion components for modern interfaces.

Star on GitHub