CountUp
Viewport-triggered animated counter with cubic ease-out. Triggers once when element scrolls into view.
Use and inspectUse this pattern for the stated interaction and keep its keyboard path visible. Reset preview remounts the local example; it does not establish production persistence or a backend integration. Add a reduced-motion fallback where the source animates.Next: inspect the source and adapt its event or data boundary.
CountUpViewport-triggered animated counter with cubic ease-out. Triggers once when element scrolls into view.
"use client";
import { useState, useEffect, useRef } from "react";
import { useInView } from "framer-motion";
type CountUpProps = {
end: number;
start?: number;
duration?: number;
prefix?: string;
suffix?: string;
locale?: boolean;
};
export function CountUp({
end,
start = 0,
duration = 1.8,
prefix = "",
suffix = "",
locale = true,
}: CountUpProps) {
const ref = useRef<HTMLSpanElement>(null);
const isInView = useInView(ref, { once: true });
const [value, setValue] = useState(start);
useEffect(() => {
if (!isInView) return;
const ms = duration * 1000;
const t0 = performance.now();
let raf: number;
function tick(now: number) {
const p = Math.min((now - t0) / ms, 1);
const eased = 1 - Math.pow(1 - p, 3);
setValue(Math.round(start + (end - start) * eased));
if (p < 1) raf = requestAnimationFrame(tick);
}
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [isInView, start, end, duration]);
const formatted = locale ? value.toLocaleString() : String(value);
return (
<span ref={ref}>
{prefix}{formatted}{suffix}
</span>
);
}Install
npm install framer-motion
This component requires framer-motion.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
end* | number | — | Target value to count to |
start | number | 0 | Starting value |
duration | number | 1.8 | Animation duration in seconds |
prefix | string | "" | Text before the number |
suffix | string | "" | Text after the number (e.g. %) |
locale | boolean | true | Format with toLocaleString() |