Anatomy of Scroll Progress
How useScroll feeds a spring into scaleX (or a strokeDashoffset ring), and why reduced motion just stiffens the spring.
↓Scroll to step through it
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.
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.
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.
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.
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.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`}/>Anatomy of Scroll Progress
How useScroll feeds a spring into scaleX (or a strokeDashoffset ring), and why reduced motion just stiffens the spring.
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.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`}/>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.
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,);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.
- 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]);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.
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.
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
scaleScale spring / pressrotateRotationopacityFade / cross-fadestroke-dashoffsetSVG stroke-dashoffset draw