16 KiB
AnimatedCount — Lazy NumberFlow via State Machine
Background
The /images feed renders ~30 <ImagesCard> components within the
IntersectionObserver rootMargin window (currently '100% 0px'). Each card
mounts a <Reactions> block containing 6 <AnimatedCount> instances
(like, dislike, heart, laugh, cry, tipped amount), so the steady-state count
is ~180 <NumberFlow> custom elements active at any time.
A 2026-04-28 trace of active scrolling on /user/<name>/images?sort=Most+Reactions
showed:
getBoundingClientRect— 781 + 174 = 955 samples during a 14.6 s scrolllite-BTIaQdTe.mjs(NumberFlow lite bundle) — 125 samplesNumberFlow-client-*.mjs(React wrapper) — 62 samplesclearTimeout— ~5700 samples (cancellation churn from rapid React updates cascading through NumberFlow's animation orchestration)- 92 % frame drop rate during scroll
A code comment at src/components/Metrics/useLiveMetrics.ts:34-38 already names the failure mode:
shallow equality is critical here: without it… every global store update re-renders every consumer on the page (model/image/article/creator cards all subscribe), causing a re-render storm + NumberFlow allocation pressure under signal traffic.
This document proposes a redesign of <AnimatedCount> that only mounts
<NumberFlow> while a value is actively transitioning, falling back to
plain text the rest of the time.
The state machine approach
Stages
┌─────────┐ value changes ┌──────────┐ rAF ┌────────────┐
│ idle │─────────────────────▶│ priming │────────────▶│ animating │
│ <span> │ │<NumberFlow│ │<NumberFlow │
│ │◀─────────────────────│ value=old│ │ value=new │
└─────────┘ onAnimationsFinish └──────────┘ └────────────┘
| Stage | What renders | When entered | When exited |
|---|---|---|---|
idle |
Plain <span> with formatted text |
Initial mount; after animation completes | When value changes |
priming |
<NumberFlow value={oldValue} /> |
When a value change is detected | After one requestAnimationFrame tick |
animating |
<NumberFlow value={newValue} onAnimationsFinish={...} /> |
After the priming frame | When NumberFlow fires onAnimationsFinish |
Why two NumberFlow stages instead of one
NumberFlow needs to see the previous value first, then the new value, to know what to morph from and to. If we mount it directly with the new value, it has nothing to animate from — it appears statically with the new value and the visual flourish is lost.
The priming stage exists for exactly one frame: long enough for the
custom element to attach to the DOM with the old value. Then the next
requestAnimationFrame tick swaps the value to the new one, which
triggers NumberFlow's attributeChangedCallback → willUpdate →
didUpdate → animation cascade.
Pseudocode
type Stage = 'idle' | 'priming' | 'animating';
export function AnimatedCount({ value, abbreviate = true, animate = true }) {
const [stage, setStage] = useState<Stage>('idle');
const prevValueRef = useRef(value);
// ... existing spanRef, floatingDelta state for the highlight pulse stays
// unchanged — it runs independently of the morph stage.
useEffect(() => {
if (!animate || value === prevValueRef.current) return;
if (stage !== 'idle') return; // already in flight; let coalescing handle it
setStage('priming');
const id = requestAnimationFrame(() => setStage('animating'));
return () => cancelAnimationFrame(id);
}, [value, stage, animate]);
// existing pulse + floating-delta effect remains here, unchanged
if (!animate || stage === 'idle') {
return <span ref={spanRef} className={...}>{format(value)}<floatingDelta/></span>;
}
if (stage === 'priming') {
return <span ref={spanRef} className={...}>
<NumberFlow value={prevValueRef.current} format={...} />
<floatingDelta/>
</span>;
}
// stage === 'animating'
return <span ref={spanRef} className={...}>
<NumberFlow
value={value}
format={...}
onAnimationsFinish={() => {
prevValueRef.current = value;
setStage('idle');
}}
/>
<floatingDelta/>
</span>;
}
The highlight pulse + floating "+N" indicator from the current implementation are preserved — they run on every value change regardless of stage, so users still see something happen even on cards where the morph is suppressed (e.g., reduced motion).
Edge cases
| Case | Handling |
|---|---|
Value changes again while animating |
The <NumberFlow> is still mounted; just re-render with the new value. NumberFlow internally coalesces — it'll either redirect mid-animation or chain to a new transition. We update prevValueRef.current only on onAnimationsFinish so the next priming step has the right "from" value. |
Value changes while priming (sub-frame) |
Race is harmless. The rAF callback in flight will fire setStage('animating') and the latest value will be in scope. NumberFlow will animate from prevValueRef.current to the latest value, skipping intermediate values. |
| Component unmounts during animation | rAF cleanup in the effect cancels the priming step; React unmounts NumberFlow normally. No leaks. |
prefers-reduced-motion: reduce |
Pass respectMotionPreference={true} to <NumberFlow>. NumberFlow internally falls back to instant updates without the FLIP animation. (Currently we pass false to override; this should change.) |
| Initial mount | Stage starts in idle, renders plain <span> immediately. No NumberFlow allocation on mount. This is the biggest win. |
| Rapid succession (e.g., 5 reactions in 1s) | First change → priming → animating. Subsequent changes during animating → re-render NumberFlow with new value, no stage churn. After last animation finishes → idle. Total: 1 mount, 1 unmount, 5 internal NumberFlow updates. |
Visual consistency
The plain <span> and <NumberFlow> render slightly differently because
NumberFlow uses font-variant-numeric: tabular-nums internally so digits
have fixed widths (required for the morph animation to look right). Without
matching this on the plain <span>, the swap into priming will cause a
~1px width shift that catches the eye.
Mitigation: apply font-variant-numeric: tabular-nums to the wrapper
<span> so both modes render at identical width. Single CSS rule in
AnimatedCount.module.css.
void el.offsetWidth (current line 55)
The current implementation has a deliberate forced layout to restart the highlight CSS animation. This stays — it's a one-shot read on a known element, not the per-frame storm we're trying to eliminate. ~1 layout per value change is fine.
Why NumberFlow has so much overhead
NumberFlow is built around the FLIP animation technique ("First, Last,
Invert, Play"), applied recursively to a tree of digit elements. FLIP is
the gold-standard pattern for animating layout changes — it's how
react-transition-group, Framer's layout, and most modern animation
libraries work — and it produces silky animations because the actual
animation runs on the compositor as a pure transform.
The cost is that FLIP requires synchronous layout measurements on every transition. Each "frame" of an update has two layout reads:
willUpdate()— measure positions BEFORE the DOM mutates- (apply DOM change)
didUpdate()— measure positions AFTER the DOM mutates- Compute delta, schedule a transform animation that interpolates from the old position back to the new
For a single number, this is fine. NumberFlow's expense compounds because
it applies FLIP at three levels of the tree, each of which calls
getBoundingClientRect() independently:
NumberFlow root
├── willUpdate() — root.getBoundingClientRect() (line 210)
│ └── didUpdate() — root.getBoundingClientRect() again (line 216)
├── Section (integer)
│ ├── willUpdate() — section.getBoundingClientRect() (line 277)
│ ├── didUpdate() — section.getBoundingClientRect() (line 281)
│ ├── Digit '1'
│ │ ├── willUpdate() — digit.getBoundingClientRect() (line 364)
│ │ └── didUpdate() — digit.getBoundingClientRect() (line 373)
│ └── Digit '2', '3', '4' — same pattern
└── Section (fraction) — same pattern if applicable
For a 4-digit number with thousands separator (e.g., "1,234"), this is
roughly ~14 getBoundingClientRect calls per update (2 each for: root,
1 section, 4 digits, 1 separator). At 60 fps with a single value change
animation lasting 600 ms, that's ~14 reads × ~36 frames per animation.
Why three levels? Because each level animates independently:
- Each digit slides vertically when its value changes (e.g., 4 → 5)
- Each section shifts horizontally when digits are added or removed (e.g., 99 → 100 grows the integer section by one digit width)
- The whole number can shift if its width changes relative to surrounding text (e.g., for right-aligned counters)
If any one level skipped its measurements, the animation at that level would jitter or jump instead of morphing smoothly. The library is doing exactly what it needs to do.
What the animated: false flag does (and doesn't do)
NumberFlow exposes an animated: boolean prop. Setting it to false
gates some of the FLIP work — specifically, the per-digit
willUpdate cascade is conditionally skipped at
lite-BTIaQdTe.mjs line 264:
if (X(t, (r) => { ... }, { reverse: s }), this.flow.computedAnimated) {
const r = this.el.getBoundingClientRect();
e.forEach((o) => { o.willUpdate(r); });
}
But the outer willUpdate/didUpdate methods on the section and root
classes (lines 210, 216, 276, 280) run unconditionally even when
animated=false. So setting the flag eliminates ~30% of the per-update
gBCR cost but leaves the section + root reads in place.
A small upstream patch (early-return when !computedAnimated in those
two methods) would make animated=false honor its name fully. We could
ship that as a pnpm patch alongside the Mantine one, but it's only
necessary if we keep using NumberFlow in the always-mounted pattern.
The lazy-mount approach in this document doesn't need it because the
custom element only exists during transitions where we want the
animation to run.
React wrapper overhead
@number-flow/react wraps the NumberFlowLite custom element in a React
component. The wrapper:
- Synchronizes React props with the custom element's observed attributes
(
data,digits) - Passes through callbacks (
onAnimationsStart,onAnimationsFinish) - Uses
useCanAnimateto trackprefers-reduced-motion
Each mount/update flows: React update → wrapper component renders → set
attribute on custom element → attributeChangedCallback fires →
NumberFlow's internal update path runs. With ~180 instances on the feed,
even cheap React-level work multiplies.
Custom element + ShadowRoot cost
NumberFlow uses a Web Component with Shadow DOM. Each instance:
- Allocates a ShadowRoot (separate DOM tree, separate style scope)
- Imports/installs internal CSS via
adoptedStyleSheetsor inline<style> - Maintains its own connected/disconnected lifecycle
- Triggers separate style recalc passes from the light DOM
ShadowRoot construction is ~0.5–1 ms on typical hardware. With 180 instances on the feed, that's ~150 ms of ShadowRoot allocation just from mounting cards as the user scrolls. The lazy-mount approach eliminates this entirely for cards whose values don't change.
Could we build our own NumberFlow without the overhead?
Short answer: yes, and it would be smaller than you'd expect — but only if we narrow the feature set to what we actually use.
What our use cases need
Surveying the codebase:
Reactions.tsx— integer counts, sometimes compact-formatted (1k, 1.2M)BuzzTippingBadge— integer counts, no compact formatCurrencyBadge— integer (buzz amount), no compact formatAuctionUtils— integer
We do NOT use:
- Decimals (no fractional digits in any current consumer)
- Locale-specific number formatting (always English)
- Scientific notation
- Prefix/suffix props
- Per-digit configuration (
digitsprop) - Custom timing (
transformTiming,spinTiming,opacityTiming) - Plugins (the
Pluginsystem in number-flow)
This means a domain-specific replacement could drop ~70 % of NumberFlow's surface area. Once you do that, several cheaper animation strategies become viable.
Approach A — CSS digit roll (no gBCR, all on compositor)
Render each digit position as a vertical column showing 0–9 stacked.
Show digit n by applying transform: translateY(-n * 100%). Animate
transitions by transitioning the transform property.
<span class="counter">
<span class="digit" style="--n: 1"><span>0</span><span>1</span>...<span>9</span></span>
<span class="digit" style="--n: 2"><span>0</span><span>1</span>...<span>9</span></span>
<span class="digit" style="--n: 3"><span>0</span><span>1</span>...<span>9</span></span>
<span class="digit" style="--n: 4"><span>0</span><span>1</span>...<span>9</span></span>
</span>
.digit {
display: inline-block;
height: 1em;
overflow: hidden;
}
.digit > span {
display: block;
height: 1em;
transform: translateY(calc(var(--n) * -1em));
transition: transform 0.4s ease;
}
Properties:
- Zero layout reads. The animation is
transformonly, runs entirely on the compositor. - Width changes (1 → 100) require a different mechanism. Adding/removing digit columns triggers a real layout. Acceptable trade-off — width changes are rare on reactions (usually stable in their order of magnitude during a session).
- No ShadowRoot. Light-DOM only.
- ~50 lines of code. Maybe 80 with compact formatting (1k, 1.2M).
- Visual matches NumberFlow's "spin" mode for the common case where only one or two digits change.
This is the closest match to NumberFlow's visual without any of the cost.
Approach B — rAF interpolation (count-up style)
Use requestAnimationFrame to interpolate from old to new value over the
animation duration, rendering as plain text on each frame.
useAnimationFrame(({ progress }) => {
const v = Math.round(prevValue + (newValue - prevValue) * progress);
setDisplayValue(v);
});
Properties:
- Zero layout reads.
- Visually counts through values: 1, 2, 3, …, 99 instead of morphing.
- ~20 lines of code.
- Less satisfying than the digit roll for small changes, but fine for large jumps.
This is what react-countup does. Could be inlined as a hook to avoid the
dep.
Approach C — Snap + pulse (no animation library)
Snap to the new value instantly; rely on the existing CSS highlight pulse + floating "+N" indicator to communicate that something changed.
- Zero animation work.
- ~5 lines of code (just the formatter call).
- Simplest visual — close to what most apps do.
Recommendation
If we go down the path of replacing NumberFlow, Approach A (CSS digit roll) is the most interesting candidate. It preserves the visual character of NumberFlow exactly for the common case (digit morphs in place), eliminates every category of overhead we identified, and is small enough to maintain in-tree.
But for the immediate fix — eliminating the per-card mount cost during scroll — the lazy-mount state machine in the first half of this document is the right starting point. It works regardless of which animation library is underneath, lets us keep NumberFlow as-is for now, and gives us a clean place to swap in a custom roll component later if we decide to.
The order I'd suggest:
- Implement the lazy-mount state machine (this document). Ships the perf win immediately.
- After validating the fix in production traces, decide whether the remaining NumberFlow surface area (mount cost on actual transitions) is worth replacing with a custom CSS digit-roll component. That's a separate, larger piece of work and not blocking.