GodUIGodUI
129Follow on X

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.

01/04Anatomy

useMotionValue

useSpring

on("change")

motionValue
raw target, set once in view
spring
damping 60 · stiffness 100
formatted span
Intl.NumberFormat → textContent
tsx
const motionValue = useMotionValue(direction === "down" ? value : startValue,);const springValue = useSpring(motionValue, { damping, stiffness });const isInView = useInView(ref, { once: true, margin: "0px" });
01

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.

02/04The spring

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
tsx
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],);
02

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.

03/04The in-view gate

once

delay

set()

in view
useInView(ref, { once: true })
delay
setTimeout(delay * 1000)
set target
motionValue.set(target)
tsx
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);}, [/* … */]);
03

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.

04/04Result
0up · 0→100
0.00decimals
100down · delay 0.4s
04

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.

tsx
 
if (reduceMotion) springValue.jump(target);

Motion Score

Number TickerSS: Compositor-only
StransformCompositor transform (translate / scale / rotate)
SopacityFade / cross-fade
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 →