Number Counter Animation for React
Animated number counter that counts from 0 to target. Tabular nums prevent layout shift, smooth ease-out-expo easing, respects reduced motion.
Component
00+0
"use client";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
function easeOutExpo(t: number): number {
return t === 1 ? 1 : 1 - 2 ** (-10 * t);
}
function useReducedMotion(): boolean {
const [reduced, setReduced] = useState(false);
useEffect(() => {
const mq = window.matchMedia(
"(prefers-reduced-motion: reduce)",
);
setReduced(mq.matches);
const handler = () => setReduced(mq.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
return reduced;
}
interface NumberCounterProps {
value: number;
className?: string;
duration?: number;
trigger?: "mount" | "inView";
inViewThreshold?: number;
suffix?: string;
prefix?: string;
}
export function NumberCounter({
value,
className,
duration = 1500,
trigger = "inView",
inViewThreshold = 0.3,
suffix = "",
prefix = "",
}: NumberCounterProps) {
const [display, setDisplay] = useState(0);
const [inView, setInView] = useState(trigger === "mount");
const ref = useRef<HTMLSpanElement>(null);
const hasAnimatedRef = useRef(false);
const shouldReduceMotion = useReducedMotion();
useEffect(() => {
if (trigger !== "inView") {
return;
}
const el = ref.current;
if (!el) {
return;
}
const obs = new IntersectionObserver(
([e]) => {
if (e?.isIntersecting && !hasAnimatedRef.current) {
setInView(true);
}
},
{ threshold: inViewThreshold },
);
obs.observe(el);
return () => obs.disconnect();
}, [trigger, inViewThreshold]);
useEffect(() => {
if (trigger === "mount" ? false : !inView) {
return;
}
if (hasAnimatedRef.current) {
return;
}
hasAnimatedRef.current = true;
if (shouldReduceMotion) {
setDisplay(value);
return;
}
const start = performance.now();
const startVal = 0;
const tick = (now: number) => {
const elapsed = now - start;
const progress = Math.min(elapsed / duration, 1);
const eased = easeOutExpo(progress);
const current = Math.round(
startVal + (value - startVal) * eased,
);
setDisplay(current);
if (progress < 1) {
requestAnimationFrame(tick);
}
};
requestAnimationFrame(tick);
}, [
value,
duration,
trigger,
inView,
shouldReduceMotion,
]);
const digitCount = String(value).length;
return (
<span
ref={ref}
className={cn("inline-block tabular-nums", className)}
style={{ minWidth: `${digitCount}ch` }}
>
{prefix}
{display}
{suffix}
</span>
);
}Installation
1. Copy the component file
"use client";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
function easeOutExpo(t: number): number {
return t === 1 ? 1 : 1 - 2 ** (-10 * t);
}
function useReducedMotion(): boolean {
const [reduced, setReduced] = useState(false);
useEffect(() => {
const mq = window.matchMedia(
"(prefers-reduced-motion: reduce)",
);
setReduced(mq.matches);
const handler = () => setReduced(mq.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
return reduced;
}
interface NumberCounterProps {
value: number;
className?: string;
duration?: number;
trigger?: "mount" | "inView";
inViewThreshold?: number;
suffix?: string;
prefix?: string;
}
export function NumberCounter({
value,
className,
duration = 1500,
trigger = "inView",
inViewThreshold = 0.3,
suffix = "",
prefix = "",
}: NumberCounterProps) {
const [display, setDisplay] = useState(0);
const [inView, setInView] = useState(trigger === "mount");
const ref = useRef<HTMLSpanElement>(null);
const hasAnimatedRef = useRef(false);
const shouldReduceMotion = useReducedMotion();
useEffect(() => {
if (trigger !== "inView") {
return;
}
const el = ref.current;
if (!el) {
return;
}
const obs = new IntersectionObserver(
([e]) => {
if (e?.isIntersecting && !hasAnimatedRef.current) {
setInView(true);
}
},
{ threshold: inViewThreshold },
);
obs.observe(el);
return () => obs.disconnect();
}, [trigger, inViewThreshold]);
useEffect(() => {
if (trigger === "mount" ? false : !inView) {
return;
}
if (hasAnimatedRef.current) {
return;
}
hasAnimatedRef.current = true;
if (shouldReduceMotion) {
setDisplay(value);
return;
}
const start = performance.now();
const startVal = 0;
const tick = (now: number) => {
const elapsed = now - start;
const progress = Math.min(elapsed / duration, 1);
const eased = easeOutExpo(progress);
const current = Math.round(
startVal + (value - startVal) * eased,
);
setDisplay(current);
if (progress < 1) {
requestAnimationFrame(tick);
}
};
requestAnimationFrame(tick);
}, [
value,
duration,
trigger,
inView,
shouldReduceMotion,
]);
const digitCount = String(value).length;
return (
<span
ref={ref}
className={cn("inline-block tabular-nums", className)}
style={{ minWidth: `${digitCount}ch` }}
>
{prefix}
{display}
{suffix}
</span>
);
}2. Import and use
import { NumberCounter } from "@/components/number-counter";
<NumberCounter value={9876} />;Usage
Import
Add the NumberCounter import.
import { NumberCounter } from "@/components/number-counter";Use
Pass the target value.
<NumberCounter value={9876} />;Guidelines
- value: target number to count up to. Animation starts from 0.
- trigger='inView' (default): animates when scrolled into view. trigger='mount': starts immediately.
- Use tabular-nums (built-in) and minWidth to prevent layout shift as digits change.
- suffix and prefix for values like '35+' or '$1,000'.
Props
All props are optional unless marked required. Use these to customize every aspect of the component.
| Prop | Type | Default | Description |
|---|---|---|---|
| value | number | - | Target number to count up to. |
| className | string | - | Additional CSS classes. |
| duration | number | 1500 | Animation duration in ms. |
| trigger | "mount" | "inView" | "inView" | "mount" = start on mount. "inView" = animate when scrolled into view. |
| inViewThreshold | number | 0.3 | IntersectionObserver threshold for inView trigger. |
| suffix | string | "" | Text appended after the number (e.g. '+', 'K'). |
| prefix | string | "" | Text prepended before the number (e.g. '$'). |
Accessibility
- Decorative numeric display rendered as a plain span with no interactive controls, buttons, inputs, or focusable elements.
- No ARIA live region is set, so the rapidly changing digits are not announced to screen readers in a controlled way; consider rendering the final value as accessible text if the count conveys meaningful information.
- Honours prefers-reduced-motion via window.matchMedia('(prefers-reduced-motion: reduce)'), skipping the animation and jumping straight to the final value when reduced motion is requested.
- The reduced-motion media query listener is registered and cleaned up on unmount, so live changes to the OS preference are respected.
- Uses tabular-nums and a minWidth sized in ch units so the displayed number does not cause layout shift or visual jumpiness as digits change.
Performance
- Animates the numeric text content via setState on each frame rather than a CSS property, so it triggers React re-renders and text reflow inside the span instead of cheap compositor-only transform or opacity changes.
- Drives the count with requestAnimationFrame and stops scheduling frames once progress reaches 1, avoiding an open-ended timer loop.
- Defers work until visible by using an IntersectionObserver for the inView trigger, and a hasAnimatedRef guard ensures the animation runs only once.
- Cleans up both the IntersectionObserver (disconnect) and the matchMedia change listener on unmount, preventing leaked observers and listeners.
- Layout impact is minimal because the span is fixed-width via minWidth in ch and tabular-nums, so digit changes do not reflow surrounding content; no will-change or explicit GPU compositing hints are used.
Examples
Basic
Counts from 0 to 9876 when scrolled into view.
00+0
import { NumberCounter } from "@/components/number-counter";
<NumberCounter
value={9876}
className="font-bold text-2xl"
/>;With suffix
With suffix for values like 35+.
00+0
import { NumberCounter } from "@/components/number-counter";
<NumberCounter
value={35}
suffix="+"
className="font-bold text-2xl"
/>;On mount
Starts on mount, 2 second duration.
00+0
import { NumberCounter } from "@/components/number-counter";
<NumberCounter
value={24}
trigger="mount"
duration={2000}
/>;Related reading
- Easing Curves That Feel Natural — The easing that makes counting feel alive
- Choreographing Multi-Step Motion — Driving the count with timed steps
Last updated on Jun 19