A shared value drives the animation, setState renders the text. A useSharedValue is retargeted with withTiming on every value change, and a useAnimatedReaction watches it and calls setDisplay through runOnJS on every tick. This isn't the fully zero-JS-thread TextInput/animatedProps trick some Reanimated demos use, that requires the animated component to forward a real native ref, which this library's NativeWind-wrapped Text doesn't reliably do. setState-per-tick is simpler, keeps className/dark: styling working normally, and is plenty fast for a number ticker.
formatter never runs on the UI thread. The reaction callback is a worklet; calling an arbitrary JS closure like a caller-supplied formatter directly inside it would throw. Only the bare number crosses over runOnJS, and formatter runs after that, back on the JS thread.
One shared value drives everything. There's never a second parallel animation, every value change re-targets the same shared value, so rapid-fire updates (a live score ticking every few seconds) always resolve from wherever the number currently sits, not from a stale start point.
withTiming, not withSpring, on purpose. A spring's overshoot would make the number wobble past its target and settle back, which reads wrong for a quantity. Easing.out(Easing.cubic) gives a fast-then-settle curve without ever overshooting.
The first render doesn't animate. A mount guard skips the initial withTiming call so the counter lands exactly on its starting value instead of counting up from 0 the moment it appears.
duration (default 800ms) and the Easing.out(Easing.cubic) curve: slow it down for a large jump like a hero stat, speed it up for a frequently-updating live value
Swap Math.round in the default formatter for Math.floor if you never want the counter to visually round up past the true value
To make the counter always animate, including on mount, remove the mount guard in the source
See exactly how this works with an RSLAB subscription.