Number Counter
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
pnpm dlx shadcn@latest add "https://pulkitxm.com/components/number-counter.json"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. '$'). |
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}
/>;Last updated on Mar 10