Count-Up Stats
A dark stats band whose numbers count up when scrolled into view, with prefixes and suffixes. React and Tailwind CSS, no animation library.
CountUpStats.tsx
"use client";
// A row of large numbers that count up when they scroll into view, on a dark band.
// Pure Tailwind + React, no animation library. Works with Tailwind v3 and v4.
import { useEffect, useRef } from "react";
type Stat = { value: number; label: string; prefix?: string; suffix?: string };
type Props = {
heading?: string;
stats?: Stat[];
};
const DEFAULT_STATS: Stat[] = [
{ value: 28, label: "Summers on the Mediterranean" },
{ value: 1400, suffix: "+", label: "Charters arranged for private clients" },
{ value: 96, suffix: "%", label: "Of guests return or recommend us" },
{ value: 3.2, prefix: "€", suffix: "bn", label: "In yacht sales brokered" },
];
const format = (n: number, decimals: number) =>
n.toLocaleString("en-US", { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
const decimalsOf = (value: number) => (Number.isInteger(value) ? 0 : String(value).split(".")[1].length);
// Renders the final value on the server; counts from zero once visible
function Counter({ value }: { value: number }) {
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
const decimals = decimalsOf(value);
const el = ref.current;
if (!el || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let frame = 0;
const observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return;
observer.disconnect();
const start = performance.now();
const tick = (now: number) => {
const t = Math.min((now - start) / 2200, 1);
const eased = t === 1 ? 1 : 1 - Math.pow(2, -10 * t); // expo out
el.textContent = format(value * eased, decimals);
if (t < 1) frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
},
{ rootMargin: "0px 0px -10% 0px" },
);
observer.observe(el);
return () => {
observer.disconnect();
cancelAnimationFrame(frame);
};
}, [value]);
return <span ref={ref}>{format(value, decimalsOf(value))}</span>;
}
export default function CountUpStats({
heading = "Three decades on the water",
stats = DEFAULT_STATS,
}: Props) {
return (
<section className="bg-[#0b131a] py-24 font-['Helvetica_Neue',Helvetica,Arial,sans-serif] text-white antialiased md:py-32">
<div className="mx-auto max-w-[100rem] px-4 md:px-8 lg:px-12">
{heading && (
<h2 className="mb-14 text-[clamp(1.875rem,3.3vw,3.5rem)] font-normal leading-[1.05] tracking-[-0.03em] md:mb-20 md:max-w-3xl">
{heading}
</h2>
)}
<div className="grid grid-cols-2 gap-x-6 gap-y-14 lg:grid-cols-4">
{stats.map((s) => (
<div key={s.label} className="border-t border-white/15 pt-8">
<p className="text-[clamp(2.25rem,4.5vw,4.75rem)] leading-none tracking-[-0.035em] tabular-nums">
{s.prefix}
<Counter value={s.value} />
{s.suffix && <span className="text-[#dcc7a0]">{s.suffix}</span>}
</p>
<p className="mt-4 max-w-56 text-sm leading-relaxed text-white/60">{s.label}</p>
</div>
))}
</div>
</div>
</section>
);
}
How to use it
- Make sure your Next.js or React project has Tailwind CSS set up.
- Create
components/CountUpStats.tsxand paste the code. - Import it into a page and replace the placeholder text and images with your own.
More free components
All features →
Ready for a faster site that's easier to edit?
Book a free 30-minute call. We'll review your current site and send a fixed-price plan. No pressure, no pitch.
Book Free Call No commitment required


