CountUp
Data DisplayAnimated numeric transitions with an immediate accessible target value.
Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.
"use client";
import { CountUp } from "@/components/ui/count-up";
import { Button } from "@dashboardpack/core/components/ui/button";
import { useState } from "react";
export default function CountUpExample() {
const [key, setKey] = useState(0);
const [seats, setSeats] = useState(128);
return (
<div className="w-full max-w-xl space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-xl border bg-card p-5">
<p className="mb-3 text-xs text-muted-foreground">Monthly revenue</p>
<CountUp
key={key}
value={48240.75}
prefix="$"
decimals={2}
duration={1200}
className="text-3xl font-semibold tabular-nums tracking-tight"
/>
</div>
<div className="rounded-xl border bg-card p-5">
<p className="mb-3 text-xs text-muted-foreground">Active seats</p>
<CountUp
value={seats}
suffix=" seats"
className="text-3xl font-semibold tabular-nums tracking-tight"
/>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onClick={() => setKey((value) => value + 1)}
>
Replay revenue
</Button>
<Button
type="button"
variant="outline"
onClick={() => setSeats((value) => value + 10)}
>
Add 10 seats
</Button>
</div>
<p className="text-xs text-muted-foreground">
Assistive technology reads the final value. Reduced motion skips the
animation.
</p>
</div>
);
}Usage
Use as a decorative enhancement beside a metric label. The server and initial render show the actual target value; animation starts after hydration. The first animation starts from from, and later target changes animate from the last displayed value. Remount to replay an initial count. Duration is bounded to ten seconds and decimal precision to six places. Prefix and suffix are text, not HTML. Formatting is en-US; non-finite values become zero.
import { CountUp } from "@/components/ui/count-up";Shipped as editable source in src/components/ui/count-up.tsx in both apps. Copy the implementation below when moving it to another project. Copy the complete example from Source code for all imports and state. Add "use client" when using hooks or event handlers. See installation and theming for project setup.
Component source
The complete implementation is included in both apps. It uses the React, Radix, Lucide, and shared-core dependencies already installed in Apex. Keep your project’s semantic theme tokens when reusing it.
src/components/ui/count-up.tsx
"use client";
import { useEffect, useRef, useState } from "react";
export interface CountUpProps {
value: number;
from?: number;
duration?: number;
decimals?: number;
prefix?: string;
suffix?: string;
className?: string;
}
export function CountUp({
value,
from = 0,
duration = 1000,
decimals = 0,
prefix = "",
suffix = "",
className,
}: CountUpProps) {
const target = Number.isFinite(value) ? value : 0;
const [display, setDisplay] = useState(target);
const current = useRef<number | null>(null);
const precision = Math.max(0, Math.min(6, Math.floor(decimals) || 0));
useEffect(() => {
const start = current.current ?? (Number.isFinite(from) ? from : 0);
const length = Math.max(
0,
Math.min(10000, Number.isFinite(duration) ? duration : 1000),
);
const media = matchMedia("(prefers-reduced-motion: reduce)");
let frame = 0;
let started: number | undefined;
let cancelled = false;
const tick = (time: number) => {
if (cancelled) return;
started ??= time;
const progress =
media.matches || length === 0
? 1
: Math.min(1, (time - started) / length);
const next = start + (target - start) * (1 - Math.pow(1 - progress, 3));
current.current = next;
setDisplay(next);
if (progress < 1) frame = requestAnimationFrame(tick);
};
const finish = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
current.current = target;
setDisplay(target);
});
};
frame = requestAnimationFrame(tick);
media.addEventListener("change", finish);
return () => {
cancelled = true;
cancelAnimationFrame(frame);
media.removeEventListener("change", finish);
};
}, [target, from, duration]);
const format = (number: number) =>
`${prefix}${new Intl.NumberFormat("en-US", { minimumFractionDigits: precision, maximumFractionDigits: precision }).format(number)}${suffix}`;
return (
<span className={className}>
<span aria-hidden>{format(display)}</span>
<span className="sr-only">{format(target)}</span>
</span>
);
}API reference
Common props and defaults. Slot-specific props are named below; native elements also accept their standard HTML attributes. The component TypeScript definitions are the complete type reference.
| Prop | Type / Default | Description |
|---|---|---|
| value | numberDefault: required | Target value. |
| from | numberDefault: 0 | Initial animation start. |
| duration | numberDefault: 1000 | Animation duration in milliseconds, clamped to 0–10000. |
| decimals | numberDefault: 0 | Formatted precision, clamped to 0–6. |
| prefix / suffix | stringDefault: empty | Text surrounding the formatted value. |
| className | stringDefault: — | Extend the container with semantic theme utilities. |