Count Up
A number that counts up when it first comes into view, pauses off-screen and never starts over.
$npx shadcn@latest add https://framebrick.co/r/count-up.jsonFrom the Tustel template, see it in context.
- Years of experience
- 80+
- Projects completed
- 120+
- Happy clients
- 45+
- Awards received
- 18
import { CountUp } from "@/components/ui/count-up";
const facts = [
{ value: 80, suffix: "+", label: "Years of experience" },
{ value: 120, from: 40, suffix: "+", label: "Projects completed" },
{ value: 45, suffix: "+", label: "Happy clients" },
{ value: 18, label: "Awards received" },
];
export default function CountUpDemo() {
return (
<dl className="grid w-full max-w-xl grid-cols-2 gap-x-8 gap-y-10 sm:grid-cols-4">
{facts.map((fact) => (
<div key={fact.label} className="flex flex-col gap-1">
<dt className="order-2 text-sm text-muted-foreground">{fact.label}</dt>
<dd className="text-4xl font-semibold tracking-tight">
<CountUp value={fact.value} from={fact.from} />
{fact.suffix}
</dd>
</div>
))}
</dl>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/count-up.json1. Install the dependencies.
npm install motion clsx tailwind-merge2. Add
cn()tolib/utils.ts(skip if shadcn already created it).import { clsx, type ClassValue } from "clsx"; import { extendTailwindMerge } from "tailwind-merge"; /* tailwind-merge must know the site's type scale (globals.css) is a font size, not a text color — otherwise cn("text-h6 text-muted-foreground") drops text-h6. */ const twMerge = extendTailwindMerge({ extend: { classGroups: { "font-size": [{ text: ["display", "h1", "h2", "h3", "h4", "h5", "h6"] }] } }, }); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into
components/ui/count-up.tsx."use client"; import * as React from "react"; import { cn } from "@/lib/utils"; /* * Count Up — a number that counts up once its first pixel comes into view (from the Tustel "Number Counter", a Framer * code component). Framer adds 1 every 5 ms, so 0 → 80 takes 0.4 s and 40 → 120 the same; it pauses while the number * is off-screen and carries on from where it was, and it never starts over. `duration` gives a number a fixed time * instead, which is what a four-digit figure needs. * * It counts by time, one frame at a time, not by a timer per step: a 5 ms interval is clamped and throttled by the * browser, a clock is not. The digits are tabular, so the width does not jitter while they change. * * What a reader gets without the animation: the final number is always in the page, in a second element — it is what * screen readers read (the moving digits are aria-hidden, so they never announce 41, 42, 43…) and what shows with * reduced motion, in print and without JavaScript. Markup never depends on reduced motion (the server cannot know it); * CSS swaps the two elements, and the effect simply does not run. */ export type CountUpProps = { /** Where it ends. */ value: number; /** Where it starts. */ from?: number; /** Milliseconds per step, as in Framer: 1 → +1 every ms; 5 → +1 every 5 ms. */ interval?: number; /** Total time in seconds, instead of a rate: a big number then takes as long as a small one. */ duration?: number; /** Intl.NumberFormat options, e.g. { useGrouping: true } or { notation: "compact" }. Plain digits by default. */ format?: Intl.NumberFormatOptions; /** Locale for `format`. Always explicit, so the server and the browser agree on the digits. */ locale?: string; className?: string; }; export function CountUp({ value, from = 0, interval = 5, duration, format, locale = "en-US", className }: CountUpProps) { const ref = React.useRef<HTMLSpanElement>(null); const [progress, setProgress] = React.useState(0); const options = format ? JSON.stringify(format) : ""; const formatter = React.useMemo(() => (options ? new Intl.NumberFormat(locale, JSON.parse(options)) : null), [locale, options]); const text = (n: number) => (formatter ? formatter.format(n) : String(n)); const total = duration !== undefined ? duration * 1000 : Math.abs(value - from) * interval; const current = progress >= 1 ? value : Math.round(from + (value - from) * progress); React.useEffect(() => { const element = ref.current; if (!element || total <= 0 || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; let elapsed = 0; let last = -1; let frame = 0; /* The first frame after (re)starting only sets the clock: a frame's timestamp can be earlier than the moment it was asked for, and counting from that would run backwards (it showed -2 first). */ const tick = (now: number) => { elapsed = Math.min(total, elapsed + (last < 0 ? 0 : Math.max(0, now - last))); last = now; setProgress(elapsed / total); if (elapsed < total) frame = requestAnimationFrame(tick); }; /* Counts while any part of it is on screen; off-screen it waits, and the clock is not running while it waits. */ const observer = new IntersectionObserver(([entry]) => { cancelAnimationFrame(frame); last = -1; if (entry.isIntersecting && elapsed < total) frame = requestAnimationFrame(tick); }); observer.observe(element); return () => { observer.disconnect(); cancelAnimationFrame(frame); }; }, [total]); return ( <span ref={ref} className={cn("tabular-nums", className)}> <span aria-hidden data-count-up="" className="print:hidden motion-reduce:hidden"> {text(current)} </span> <span data-count-up-final="" className="sr-only print:not-sr-only motion-reduce:not-sr-only"> {text(value)} </span> {/* Without JavaScript the digits would stay at `from`: this swaps in the final number, and never applies when scripts run. */} <noscript> <style>{`[data-count-up]{display:none}[data-count-up-final]{position:static;width:auto;height:auto;margin:0;overflow:visible;clip-path:none;white-space:normal}`}</style> </noscript> </span> ); }
Usage
import { CountUp } from "@/components/ui/count-up";
<p className="text-5xl font-semibold">
<CountUp value={120} from={40} />+
</p>
// a four-digit figure: a fixed time instead of Framer's rate, and separators
<CountUp value={12400} duration={1.6} format={{ useGrouping: true }} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | number | — | Where it ends. |
| from | number | 0 | Where it starts. |
| interval | number | 5 | Milliseconds per step, as in Framer: +1 every 5 ms, so 0 → 80 takes 0.4 s. |
| duration | number | — | Total time in seconds, instead of the rate: a big number then takes as long as a small one. |
| format | Intl.NumberFormatOptions | — | Formatting, e.g. { useGrouping: true } or { notation: "compact" }. Plain digits by default, as in Framer. |
| locale | string | "en-US" | Locale for `format`. Always explicit, so the server and the browser agree on the digits. |
| className | string | — | Classes for the number: its size, weight and colour. |
- It counts once its first pixel is in view, carries on from where it was after being off-screen, and never starts over.
- It counts by time, not with a timer per step: a 5 ms interval is clamped and throttled by the browser, a clock is not. The digits are tabular, so the width does not jitter while they change.
- The final number is always in the page, in a second element. Screen readers read it (the moving digits are aria-hidden, so 41, 42, 43 are never announced), and it is what shows with reduced motion, in print and without JavaScript. CSS swaps the two, so the markup never depends on reduced motion.
- Framer's rate suits small numbers: 80 takes 0.4 s, 12 000 would take a minute. Give big ones a `duration`.
- For tests: with JavaScript on, the element's textContent holds the moving digits and the final number together. Query by role and name, or read the final element (data-count-up-final).
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this component in your own project.
Create a React + Tailwind CSS component called CountUp (no animation library).
- Props: value, from (0), interval (5 — milliseconds per step, as in Framer), duration (seconds; replaces the rate),
format (Intl.NumberFormat options; plain digits when absent), locale ("en-US", always explicit so the server and the
browser agree).
- Count by time, not with a timer per step: keep the elapsed milliseconds in a closure, advance it by the frame's delta in
requestAnimationFrame, and put the progress (0 to 1) in state. The shown number is Math.round(from + (value - from) *
progress); at progress 1 it is exactly value. The total time is |value - from| * interval, or duration * 1000.
- An IntersectionObserver on the element starts the frame loop when any part of it is on screen and cancels it when it
leaves. The elapsed time is kept, so it carries on from where it was and never starts over.
- Render two spans inside one span with tabular-nums: the moving digits (aria-hidden, print:hidden, motion-reduce:hidden)
and the final number (sr-only, print:not-sr-only, motion-reduce:not-sr-only). Add a <noscript><style> that hides the
moving digits and shows the final number, so a page without JavaScript shows the real value, not the start.
- Do not start the loop when matchMedia("(prefers-reduced-motion: reduce)") matches — read it inside the effect, never
while rendering: the markup must not depend on it, because the server cannot know it.










