GodUIGodUI
129Follow on X

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 reads scrollYProgress from Framer Motion's useScroll instead of polling scroll position on a timer. It smooths the value 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 }. It is still a spring but settles almost instantly, 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, and the ring updates only strokeDashoffset, never the path geometry. The spring runs on the MotionValue pipeline instead of re-rendering React each 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 and one spring drive two skins: a bar that scales from the left, and a ring that fills until you click it to scroll back to the top.

Motion Score

Scroll ProgressCC: Paint-triggering
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 →