Animated Counter
PreviewA number that counts to its target with easing and formatting.
- counter
- number
- animation
- stats
Preview
Installation
The CLI command below is illustrative until the registry endpoint ships. Until then, copy the source from the Code tab into components/fmui/. Every component expects Tailwind CSS v4, the cn helper (clsx + tailwind-merge), and Hugeicons.
$ pnpm dlx shadcn@latest add https://fmui.dev/r/animated-counter.jsonanimated-counter/animated-counter.tsx
Usage
example.tsx
import { AnimatedCounter } from "@/components/fmui/animated-counter";
<AnimatedCounter value={downloads} duration={900} />Variants
Formatted
Pass format to render currency, percentages, or compact numbers.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | number | - | Target value. |
| duration | number | 900 | Animation length in ms. |
| format | (n: number) => string | toLocaleString | Formatter for the displayed value. |
Accessibility
- Uses aria-live=polite and aria-atomic so the final value is announced once.
- Jumps directly to the value when prefers-reduced-motion is set.
Source
"use client";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
export interface AnimatedCounterProps {
value: number;
duration?: number;
format?: (n: number) => string;
className?: string;
}
const easeOut = (t: number) => 1 - Math.pow(1 - t, 4);
const defaultFormat = (n: number) => Math.round(n).toLocaleString("en-US");
/** Animates from the previously rendered value to `value` with an ease-out curve. */
export function AnimatedCounter({ value, duration = 900, format = defaultFormat, className }: AnimatedCounterProps) {
const [display, setDisplay] = useState(value);
const fromRef = useRef(value);
const frame = useRef(0);
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const from = fromRef.current;
if (reduce || from === value) {
fromRef.current = value;
setDisplay(value);
return;
}
const start = performance.now();
const tick = (now: number) => {
const t = Math.min(1, (now - start) / duration);
const current = from + (value - from) * easeOut(t);
setDisplay(current);
if (t < 1) {
frame.current = requestAnimationFrame(tick);
} else {
fromRef.current = value;
}
};
frame.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame.current);
}, [value, duration]);
return (
<span className={cn("tabular-nums", className)} aria-live="polite" aria-atomic="true">
{format(display)}
</span>
);
}
Added Oct 23, 2026. Last updated Oct 23, 2026.