Anatomy of the Number Ticker
How a MotionValue, a spring, and an in-view gate turn a static span into a counting number without re-rendering on every frame.
↓Scroll to step through it
Anatomy
Number Ticker is three hooks and a DOM
write: a MotionValue holds the target, useSpring smooths the chase, and a
change listener formats the latest value into the span's textContent. The
spring starts only once the element is in view, after an optional delay.
The public surface is a single <span>. The logic lives in the hooks
behind it.
Direction flips the endpoints. "up" starts at startValue and springs
toward value; "down" starts at value and springs toward startValue.
The initial render paints that start endpoint as formatted text so the span
isn't empty before the first spring frame lands.
The spring
The defaults are calm: damping: 60, stiffness: 100. That gives physical
easing without the underdamped bounce of a tooltip mount.
Each frame, the spring emits a float; the change listener snaps it to the
requested decimal places and hands it to Intl.NumberFormat.
Writing textContent keeps React out of the per-frame path. The span also
carries tabular-nums tracking-wider so digit widths stay stable while the
glyphs swap, so nothing reflows when 9 becomes 1.
The in-view gate
Mounting alone does nothing. useInView with once: true opens the gate
the first time the span crosses the viewport; a setTimeout then waits
delay seconds before calling motionValue.set(target).
once: true means scrolling away and back won't re-run the count. The
ticker plays once, as a reveal. The cleanup clears the timer
so a fast unmount mid-delay never fires into a detached node.
The result
Three live instances: a plain count-up, decimals, and a delayed countdown.
useMotionValue
useSpring
on("change")
- motionValue
- raw target, set once in view
- spring
- damping 60 · stiffness 100
- formatted span
- Intl.NumberFormat → textContent
const motionValue = useMotionValue(direction === "down" ? value : startValue,);const springValue = useSpring(motionValue, { damping, stiffness });const isInView = useInView(ref, { once: true, margin: "0px" });Anatomy of the Number Ticker
How a MotionValue, a spring, and an in-view gate turn a static span into a counting number without re-rendering on every frame.
useMotionValue
useSpring
on("change")
- motionValue
- raw target, set once in view
- spring
- damping 60 · stiffness 100
- formatted span
- Intl.NumberFormat → textContent
const motionValue = useMotionValue(direction === "down" ? value : startValue,);const springValue = useSpring(motionValue, { damping, stiffness });const isInView = useInView(ref, { once: true, margin: "0px" });Anatomy
Number Ticker is three hooks and a DOM
write: a MotionValue holds the target, useSpring smooths the chase, and a
change listener formats the latest value into the span's textContent. The
spring starts only once the element is in view, after an optional delay.
The public surface is a single <span>. The logic lives in the hooks
behind it.
Direction flips the endpoints. "up" starts at startValue and springs
toward value; "down" starts at value and springs toward startValue.
The initial render paints that start endpoint as formatted text so the span
isn't empty before the first spring frame lands.
useSpring(motionValue, { damping: 60, stiffness: 100 })
- overshoot
- scaleY past 1, then settle: spring feel
- stagger
- each slot delayed 90ms, so digits cascade in
- defaults
- damping 60 · stiffness 100
React.useEffect(() => springValue.on("change", (latest) => { if (ref.current) { ref.current.textContent = Intl.NumberFormat("en-US", { minimumFractionDigits: decimalPlaces, maximumFractionDigits: decimalPlaces, }).format(Number(latest.toFixed(decimalPlaces))); } }),[springValue, decimalPlaces],);The spring
The defaults are calm: damping: 60, stiffness: 100. That gives physical
easing without the underdamped bounce of a tooltip mount.
Each frame, the spring emits a float; the change listener snaps it to the
requested decimal places and hands it to Intl.NumberFormat.
Writing textContent keeps React out of the per-frame path. The span also
carries tabular-nums tracking-wider so digit widths stay stable while the
glyphs swap, so nothing reflows when 9 becomes 1.
once
delay
set()
- in view
- useInView(ref, { once: true })
- delay
- setTimeout(delay * 1000)
- set target
- motionValue.set(target)
React.useEffect(() => {if (!isInView) return;const target = direction === "down" ? startValue : value;const timer = setTimeout(() => { motionValue.set(target); if (reduceMotion) springValue.jump(target);}, delay * 1000);return () => clearTimeout(timer);}, [/* … */]);The in-view gate
Mounting alone does nothing. useInView with once: true opens the gate
the first time the span crosses the viewport; a setTimeout then waits
delay seconds before calling motionValue.set(target).
once: true means scrolling away and back won't re-run the count. The
ticker plays once, as a reveal. The cleanup clears the timer
so a fast unmount mid-delay never fires into a detached node.
The result
Three live instances: a plain count-up, decimals, and a delayed countdown.
Reduced motion
When useReducedMotion() is true, the same gate still waits for in-view and
delay, but springValue.jump(target) lands on the final value in one frame
instead of letting the spring chase it.
if (reduceMotion) springValue.jump(target);Motion Score
transformCompositor transform (translate / scale / rotate)opacityFade / cross-fade