Number animations look great on landing pages. The naive implementation is also a great way to hurt your Core Web Vitals. Here is the version that does both well.
Stat counters that animate from zero to the final number are a common landing page pattern. Executed badly, they cause layout shift, delay rendering, and look janky on slow connections. Executed well, they add a nice touch without affecting performance metrics.
The wrong way to do this is to start animating immediately on mount, because the element might be off-screen, which wastes resources, and it might cause a repaint during the critical rendering path.
The right way uses an Intersection Observer to start the animation only when the element scrolls into view, and uses requestAnimationFrame for smooth updates that do not block the main thread.
import { useEffect, useRef, useState } from "react";
function easeOutExpo(t: number): number {
return t === 1 ? 1 : 1 - Math.pow(2, -10 * t);
}
export function useCountUp(target: number, duration: number = 1500) {
const [count, setCount] = useState(0);
const ref = useRef<HTMLElement>(null);
const hasAnimated = useRef(false);
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !hasAnimated.current) {
hasAnimated.current = true;
observer.disconnect();
const start = performance.now();
function tick(now: number) {
const elapsed = now - start;
const progress = Math.min(elapsed / duration, 1);
const eased = easeOutExpo(progress);
setCount(Math.round(eased * target));
if (progress < 1) {
requestAnimationFrame(tick);
}
}
requestAnimationFrame(tick);
}
},
{ threshold: 0.2 }
);
observer.observe(element);
return () => observer.disconnect();
}, [target, duration]);
return { count, ref };
}Usage in a component:
function StatCard({ value, label }: { value: number; label: string }) {
const { count, ref } = useCountUp(value, 1200);
return (
<div className="stat-card">
<span ref={ref as React.RefObject<HTMLSpanElement>} className="text-4xl font-bold">
{count.toLocaleString()}
</span>
<p className="text-sm text-muted-foreground">{label}</p>
</div>
);
}A few things worth noting about this implementation:
The easeOutExpo curve starts fast and decelerates at the end, which feels more natural than linear. If you want a different feel, easeOutQuad (which is t * (2 - t)) is softer.
The hasAnimated ref prevents the animation from running again if the user scrolls away and back. One shot, then done. If you want it to replay, remove that guard.
The threshold: 0.2 means the animation starts when 20% of the element is visible. Set it lower if the stat cards are at the very bottom of the viewport and never quite trigger.
For LCP impact: the initial render always shows 0, which is just a number. The animation is entirely JavaScript-driven and starts after paint, so it has no effect on LCP. The element is in the DOM immediately with its final size, so no layout shift either.
We use this on the SmuleVault landing page for the download count stats and it has never flagged in any Core Web Vitals report.