Counter Row Stat
Original · freeanimated counter stats row
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/statx-counter-row.json"use client";
import { useEffect, useRef, useState } from "react";
import type { KeyboardEvent } from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
type Tint = "indigo" | "sky" | "emerald" | "violet";
type Metric = {
id: string;
label: string;
sublabel: string;
icon: "layers" | "gauge" | "repeat" | "trending";
tint: Tint;
prefix?: string;
suffix?: string;
decimals?: number;
};
type Datum = { value: number; delta: number; trend: number[] };
type Period = {
id: string;
label: string;
data: Record<string, Datum>;
};
const METRICS: Metric[] = [
{ id: "shipped", label: "Products shipped", sublabel: "web & mobile, end to end", icon: "layers", tint: "indigo", decimals: 0 },
{ id: "lighthouse", label: "Avg. Lighthouse", sublabel: "performance, mobile", icon: "gauge", tint: "sky", suffix: "/100", decimals: 0 },
{ id: "retention", label: "Client retention", sublabel: "renewed engagements", icon: "repeat", tint: "emerald", suffix: "%", decimals: 0 },
{ id: "pipeline", label: "Pipeline influenced", sublabel: "sourced to closed", icon: "trending", tint: "violet", prefix: "$", suffix: "M", decimals: 1 },
];
const PERIODS: Period[] = [
{
id: "q",
label: "Last 90 days",
data: {
shipped: { value: 18, delta: 12.5, trend: [8, 10, 9, 12, 14, 15, 18] },
lighthouse: { value: 98, delta: 2.1, trend: [93, 94, 95, 95, 96, 97, 98] },
retention: { value: 96, delta: 3.2, trend: [88, 90, 91, 93, 94, 95, 96] },
pipeline: { value: 2.4, delta: 18.4, trend: [1.2, 1.5, 1.7, 1.9, 2.0, 2.2, 2.4] },
},
},
{
id: "y",
label: "Last 12 months",
data: {
shipped: { value: 74, delta: 9.6, trend: [40, 48, 52, 58, 63, 69, 74] },
lighthouse: { value: 97, delta: 4.0, trend: [90, 91, 93, 94, 95, 96, 97] },
retention: { value: 94, delta: 1.8, trend: [86, 88, 90, 91, 92, 93, 94] },
pipeline: { value: 11.6, delta: 22.0, trend: [5, 6.5, 7.8, 8.9, 9.7, 10.6, 11.6] },
},
},
{
id: "all",
label: "Since 2019",
data: {
shipped: { value: 312, delta: 6.4, trend: [120, 160, 200, 235, 265, 290, 312] },
lighthouse: { value: 96, delta: 5.0, trend: [82, 86, 89, 91, 93, 95, 96] },
retention: { value: 93, delta: 2.2, trend: [80, 83, 86, 88, 90, 92, 93] },
pipeline: { value: 48.9, delta: 14.7, trend: [12, 19, 26, 33, 39, 44, 48.9] },
},
},
];
const TINTS: Record<Tint, { icon: string; chip: string; spark: string; hover: string }> = {
indigo: {
icon: "text-indigo-600 dark:text-indigo-400",
chip: "bg-indigo-50 ring-indigo-100 dark:bg-indigo-500/10 dark:ring-indigo-400/20",
spark: "text-indigo-500 dark:text-indigo-400",
hover: "hover:border-indigo-300 dark:hover:border-indigo-400/40",
},
sky: {
icon: "text-sky-600 dark:text-sky-400",
chip: "bg-sky-50 ring-sky-100 dark:bg-sky-500/10 dark:ring-sky-400/20",
spark: "text-sky-500 dark:text-sky-400",
hover: "hover:border-sky-300 dark:hover:border-sky-400/40",
},
emerald: {
icon: "text-emerald-600 dark:text-emerald-400",
chip: "bg-emerald-50 ring-emerald-100 dark:bg-emerald-500/10 dark:ring-emerald-400/20",
spark: "text-emerald-500 dark:text-emerald-400",
hover: "hover:border-emerald-300 dark:hover:border-emerald-400/40",
},
violet: {
icon: "text-violet-600 dark:text-violet-400",
chip: "bg-violet-50 ring-violet-100 dark:bg-violet-500/10 dark:ring-violet-400/20",
spark: "text-violet-500 dark:text-violet-400",
hover: "hover:border-violet-300 dark:hover:border-violet-400/40",
},
};
function formatNumber(value: number, decimals: number): string {
return value.toLocaleString("en-US", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
}
function useCountUp(
target: number,
active: boolean,
restartKey: number,
reduced: boolean,
duration: number,
delay: number,
): number {
const [value, setValue] = useState<number>(0);
useEffect(() => {
if (reduced) {
setValue(target);
return;
}
if (!active) {
setValue(0);
return;
}
let raf = 0;
let startTs = 0;
setValue(0);
const tick = (ts: number) => {
if (startTs === 0) startTs = ts;
const elapsed = ts - startTs - delay;
if (elapsed < 0) {
raf = requestAnimationFrame(tick);
return;
}
const p = Math.min(elapsed / duration, 1);
const eased = p >= 1 ? 1 : 1 - Math.pow(2, -10 * p);
setValue(target * eased);
if (p < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [target, active, restartKey, reduced, duration, delay]);
return value;
}
function Icon({ name, className }: { name: Metric["icon"]; className?: string }) {
const shared = {
viewBox: "0 0 24 24",
fill: "none" as const,
stroke: "currentColor",
strokeWidth: 1.7,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
className,
"aria-hidden": true,
};
switch (name) {
case "layers":
return (
<svg {...shared}>
<path d="M12 3 3 8l9 5 9-5-9-5Z" />
<path d="M3 16l9 5 9-5" />
<path d="M3 12l9 5 9-5" />
</svg>
);
case "gauge":
return (
<svg {...shared}>
<path d="M4 18a8 8 0 1 1 16 0" />
<path d="M12 18l4-4" />
<path d="M12 18h.01" />
</svg>
);
case "repeat":
return (
<svg {...shared}>
<path d="M17 2l4 4-4 4" />
<path d="M21 6H9a5 5 0 0 0-5 5" />
<path d="M7 22l-4-4 4-4" />
<path d="M3 18h12a5 5 0 0 0 5-5" />
</svg>
);
case "trending":
default:
return (
<svg {...shared}>
<path d="M3 17l6-6 4 4 8-8" />
<path d="M21 7v6" />
<path d="M21 7h-6" />
</svg>
);
}
}
function Sparkline({ points, className }: { points: number[]; className?: string }) {
const w = 104;
const h = 34;
const pad = 3;
const min = Math.min(...points);
const max = Math.max(...points);
const range = max - min || 1;
const step = (w - pad * 2) / (points.length - 1);
const coords = points.map((p, i) => {
const x = pad + i * step;
const y = pad + (h - pad * 2) * (1 - (p - min) / range);
return [x, y] as const;
});
const d = coords
.map((c, i) => `${i === 0 ? "M" : "L"}${c[0].toFixed(1)},${c[1].toFixed(1)}`)
.join(" ");
const area = `${d} L${coords[coords.length - 1][0].toFixed(1)},${h} L${coords[0][0].toFixed(1)},${h} Z`;
let length = 0;
for (let i = 1; i < coords.length; i++) {
length += Math.hypot(coords[i][0] - coords[i - 1][0], coords[i][1] - coords[i - 1][1]);
}
const last = coords[coords.length - 1];
const gradId = `statx-fill-${points.join("-")}`;
return (
<svg
viewBox={`0 0 ${w} ${h}`}
className={className}
width={w}
height={h}
preserveAspectRatio="none"
aria-hidden="true"
>
<defs>
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="currentColor" stopOpacity="0.22" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
</linearGradient>
</defs>
<path d={area} fill={`url(#${gradId})`} stroke="none" />
<path
d={d}
className="statx-spark"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
style={{ strokeDasharray: length, strokeDashoffset: length }}
/>
<circle cx={last[0]} cy={last[1]} r={2.6} fill="currentColor" />
</svg>
);
}
function DeltaBadge({ delta }: { delta: number }) {
const up = delta >= 0;
return (
<span
className={
"inline-flex items-center gap-0.5 rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums " +
(up
? "bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400"
: "bg-rose-50 text-rose-700 dark:bg-rose-500/10 dark:text-rose-400")
}
>
<svg viewBox="0 0 12 12" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{up ? <path d="M6 9.5V2.5M6 2.5 2.5 6M6 2.5 9.5 6" /> : <path d="M6 2.5v7M6 9.5 2.5 6M6 9.5 9.5 6" />}
</svg>
{up ? "+" : "−"}
{Math.abs(delta).toFixed(1)}%
</span>
);
}
function StatCard({
metric,
datum,
index,
active,
restartKey,
reduced,
}: {
metric: Metric;
datum: Datum;
index: number;
active: boolean;
restartKey: number;
reduced: boolean;
}) {
const decimals = metric.decimals ?? 0;
const value = useCountUp(datum.value, active, restartKey, reduced, 1500, index * 110);
const shown = formatNumber(value, decimals);
const final = formatNumber(datum.value, decimals);
const tint = TINTS[metric.tint];
return (
<motion.div
initial={reduced ? false : { opacity: 0, y: 18 }}
animate={reduced ? undefined : active ? { opacity: 1, y: 0 } : { opacity: 0, y: 18 }}
transition={{ duration: 0.55, delay: index * 0.08, ease: [0.16, 1, 0.3, 1] }}
className={
"group relative flex flex-col justify-between rounded-2xl border border-slate-200 bg-white/80 p-5 shadow-sm backdrop-blur-sm transition-colors sm:p-6 " +
"dark:border-slate-800 dark:bg-slate-900/60 " +
tint.hover
}
>
<div className="flex items-start justify-between gap-3">
<span className={"inline-flex h-10 w-10 items-center justify-center rounded-xl ring-1 " + tint.chip}>
<Icon name={metric.icon} className={"h-5 w-5 " + tint.icon} />
</span>
<DeltaBadge delta={datum.delta} />
</div>
<div className="mt-6">
<div className="flex items-end gap-1">
<span className="text-4xl font-semibold tracking-tight text-slate-900 tabular-nums sm:text-[2.75rem] sm:leading-none dark:text-white" aria-hidden="true">
{metric.prefix ? <span className={"mr-0.5 align-top text-2xl " + tint.icon}>{metric.prefix}</span> : null}
{shown}
{metric.suffix ? <span className="ml-0.5 text-lg font-medium text-slate-400 dark:text-slate-500">{metric.suffix}</span> : null}
</span>
</div>
<span className="sr-only">
{metric.label}: {metric.prefix ?? ""}
{final}
{metric.suffix ?? ""}, {datum.delta >= 0 ? "up" : "down"} {Math.abs(datum.delta).toFixed(1)} percent versus the previous period.
</span>
<div className="mt-3 flex items-center justify-between gap-4">
<div>
<p className="text-sm font-semibold text-slate-700 dark:text-slate-200">{metric.label}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">{metric.sublabel}</p>
</div>
<Sparkline
key={restartKey}
points={datum.trend}
className={"h-8 w-[104px] shrink-0 " + tint.spark}
/>
</div>
</div>
</motion.div>
);
}
export default function StatxCounterRow() {
const reduced = useReducedMotion() ?? false;
const gridRef = useRef<HTMLDivElement>(null);
const inView = useInView(gridRef, { once: true, amount: 0.3 });
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
const [periodId, setPeriodId] = useState<string>(PERIODS[0].id);
const [restartKey, setRestartKey] = useState<number>(0);
const activeIndex = Math.max(0, PERIODS.findIndex((p) => p.id === periodId));
const activePeriod = PERIODS[activeIndex];
const active = inView;
const selectPeriod = (id: string) => {
setPeriodId(id);
setRestartKey((k) => k + 1);
};
const replay = () => setRestartKey((k) => k + 1);
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
let next = activeIndex;
if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (activeIndex + 1) % PERIODS.length;
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (activeIndex - 1 + PERIODS.length) % PERIODS.length;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = PERIODS.length - 1;
else return;
e.preventDefault();
selectPeriod(PERIODS[next].id);
optionRefs.current[next]?.focus();
};
const liveSummary = METRICS.map((m) => {
const d = activePeriod.data[m.id];
return `${m.label} ${m.prefix ?? ""}${formatNumber(d.value, m.decimals ?? 0)}${m.suffix ?? ""}`;
}).join(", ");
return (
<section
aria-labelledby="statx-heading"
className="relative w-full overflow-hidden bg-gradient-to-b from-white to-slate-50 px-5 py-20 sm:px-8 sm:py-24 dark:from-slate-950 dark:to-slate-900"
>
<style>{`
.statx-spark { animation: statx-draw 1.5s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
@keyframes statx-draw { to { stroke-dashoffset: 0; } }
.statx-ring { animation: statx-ring 2.6s ease-in-out infinite; }
@keyframes statx-ring {
0%, 100% { opacity: 0.55; transform: scale(1); }
50% { opacity: 0; transform: scale(2.1); }
}
@media (prefers-reduced-motion: reduce) {
.statx-spark, .statx-ring { animation: none !important; }
.statx-spark { stroke-dashoffset: 0 !important; }
}
`}</style>
<div
aria-hidden="true"
className="pointer-events-none absolute -top-24 left-1/2 h-72 w-[42rem] max-w-full -translate-x-1/2 rounded-full bg-indigo-200/40 blur-3xl dark:bg-indigo-500/10"
/>
<div className="relative mx-auto max-w-6xl">
<div className="flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-xl">
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-3 py-1 text-xs font-medium text-slate-600 dark:border-slate-700 dark:bg-slate-900/60 dark:text-slate-300">
<span className="relative flex h-2 w-2">
<span className="statx-ring absolute inset-0 rounded-full bg-emerald-500" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
</span>
Live studio data
</span>
<h2
id="statx-heading"
className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white"
>
The numbers behind every build
</h2>
<p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-400">
Outcomes we actually track across the studio, pulled from live project data instead of a pitch deck. Switch the window to see how the work compounds.
</p>
</div>
<div className="flex flex-col items-start gap-3 sm:flex-row sm:items-center">
<div
role="radiogroup"
aria-label="Choose a time window"
onKeyDown={onKeyDown}
className="inline-flex rounded-full border border-slate-200 bg-slate-100/80 p-1 dark:border-slate-800 dark:bg-slate-800/60"
>
{PERIODS.map((p, i) => {
const selected = p.id === periodId;
return (
<button
key={p.id}
ref={(el) => {
optionRefs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={selected}
tabIndex={selected ? 0 : -1}
onClick={() => selectPeriod(p.id)}
className={
"relative rounded-full px-3.5 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 dark:focus-visible:ring-offset-slate-800 " +
(selected
? "text-slate-900 dark:text-white"
: "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200")
}
>
{selected ? (
<motion.span
layoutId="statx-seg"
className="absolute inset-0 rounded-full bg-white shadow-sm ring-1 ring-black/5 dark:bg-slate-950 dark:ring-white/10"
transition={reduced ? { duration: 0 } : { type: "spring", stiffness: 380, damping: 32 }}
/>
) : null}
<span className="relative z-10">{p.label}</span>
</button>
);
})}
</div>
<button
type="button"
onClick={replay}
className="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3.5 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:border-slate-300 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:text-white dark:focus-visible:ring-offset-slate-950"
>
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
<path d="M21 4v5h-5" />
</svg>
Replay
</button>
</div>
</div>
<div
ref={gridRef}
className="mt-12 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4 lg:gap-5"
>
{METRICS.map((metric, i) => (
<StatCard
key={metric.id}
metric={metric}
datum={activePeriod.data[metric.id]}
index={i}
active={active}
restartKey={restartKey}
reduced={reduced}
/>
))}
</div>
<p className="mt-8 text-xs text-slate-400 dark:text-slate-500">
Figures reflect delivered client engagements for {activePeriod.label.toLowerCase()}. Deltas compare against the prior equivalent window.
</p>
<p className="sr-only" aria-live="polite">
Showing {activePeriod.label}. {liveSummary}.
</p>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Four Stat Metric Band
OriginalA four-column metric band with gradient figures inside a hairline-divided card, ideal for headline numbers like uptime, latency and growth.

Labelled Stats With Dividers
OriginalThree centred stats separated by responsive dividers, each pairing a headline figure with an uppercase label and supporting detail.

Trusted By Logo Cloud
OriginalA responsive six-up logo strip using simple inline-SVG marks and wordmarks with a grayscale-to-colour hover, for a trusted-by section.

Hero Stat With Supporting Copy
OriginalA two-column layout pairing one oversized hero statistic with a supporting paragraph and a checklist card explaining the number.

Count-up stats reveal
OriginalA four-card stat band whose gradient numbers count up from zero as the section scrolls into view, with staggered blur-in cards, a pulsing eyebrow, floating aurora blobs and a shimmering underline sweep.

Animated SVG progress rings
OriginalFour circular SVG progress rings that draw their gradient arcs and count up to their target percentage the moment they enter view, over a slowly rotating conic halo with a soft glow pulse behind each ring.

Staggered stat band with marquee ticker
OriginalA dark stat band where 3D-tilt cards stagger into view with count-up figures over an animated gradient mesh, finished by an infinite marquee ticker of highlight chips that pauses on hover.

Cards Stat
Originalstat cards with icons

Gradient Stat
Originalgradient stats band

Split Stat
Originalstats beside copy
Icons Stat
Originalicon stat grid

Progress Stat
Originalstats with progress rings

