Donut Stat
Original · freestats with donut charts
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-donut.json"use client";
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { motion, AnimatePresence, useReducedMotion } from "motion/react";
/* ---------------------------------- types --------------------------------- */
type RangeId = "7d" | "30d" | "90d";
interface Palette {
stroke: string;
bg: string;
text: string;
}
interface Source {
id: string;
label: string;
value: number;
color: Palette;
}
interface Kpi {
id: string;
label: string;
value: number;
unit: string;
decimals: number;
ring: number; // 0..100 fill of the donut
delta: number;
goodWhenDown: boolean;
caption: string;
color: Palette;
}
interface RangeData {
sources: Source[];
kpis: Kpi[];
}
/* -------------------------------- palettes -------------------------------- */
const INDIGO: Palette = { stroke: "stroke-indigo-500", bg: "bg-indigo-500", text: "text-indigo-500" };
const VIOLET: Palette = { stroke: "stroke-violet-500", bg: "bg-violet-500", text: "text-violet-500" };
const SKY: Palette = { stroke: "stroke-sky-500", bg: "bg-sky-500", text: "text-sky-500" };
const EMERALD: Palette = { stroke: "stroke-emerald-500", bg: "bg-emerald-500", text: "text-emerald-500" };
const AMBER: Palette = { stroke: "stroke-amber-500", bg: "bg-amber-500", text: "text-amber-500" };
/* ---------------------------------- data ---------------------------------- */
const RANGES: { id: RangeId; label: string; full: string }[] = [
{ id: "7d", label: "7D", full: "Last 7 days" },
{ id: "30d", label: "30D", full: "Last 30 days" },
{ id: "90d", label: "90D", full: "Last 90 days" },
];
function makeSources(v: [number, number, number, number, number]): Source[] {
return [
{ id: "organic", label: "Organic search", value: v[0], color: INDIGO },
{ id: "direct", label: "Direct", value: v[1], color: VIOLET },
{ id: "referral", label: "Referral", value: v[2], color: SKY },
{ id: "social", label: "Social", value: v[3], color: EMERALD },
{ id: "email", label: "Email", value: v[4], color: AMBER },
];
}
const DATA: Record<RangeId, RangeData> = {
"7d": {
sources: makeSources([5820, 3110, 1840, 1260, 640]),
kpis: [
{ id: "goal", label: "Goal completion", value: 68, unit: "%", decimals: 0, ring: 68, delta: 5.2, goodWhenDown: false, caption: "of monthly target", color: INDIGO },
{ id: "conv", label: "Conversion rate", value: 4.8, unit: "%", decimals: 1, ring: 68.6, delta: 0.6, goodWhenDown: false, caption: "against a 7% goal", color: VIOLET },
{ id: "bounce", label: "Bounce rate", value: 41.2, unit: "%", decimals: 1, ring: 41.2, delta: -1.4, goodWhenDown: true, caption: "sessions with no action", color: AMBER },
{ id: "return", label: "Returning visitors", value: 54, unit: "%", decimals: 0, ring: 54, delta: 2.0, goodWhenDown: false, caption: "of all visitors", color: EMERALD },
],
},
"30d": {
sources: makeSources([24100, 12760, 7420, 5180, 2610]),
kpis: [
{ id: "goal", label: "Goal completion", value: 72, unit: "%", decimals: 0, ring: 72, delta: 8.1, goodWhenDown: false, caption: "of monthly target", color: INDIGO },
{ id: "conv", label: "Conversion rate", value: 5.1, unit: "%", decimals: 1, ring: 72.9, delta: 0.9, goodWhenDown: false, caption: "against a 7% goal", color: VIOLET },
{ id: "bounce", label: "Bounce rate", value: 38.4, unit: "%", decimals: 1, ring: 38.4, delta: -2.6, goodWhenDown: true, caption: "sessions with no action", color: AMBER },
{ id: "return", label: "Returning visitors", value: 57, unit: "%", decimals: 0, ring: 57, delta: 3.4, goodWhenDown: false, caption: "of all visitors", color: EMERALD },
],
},
"90d": {
sources: makeSources([71300, 38200, 21100, 15400, 7900]),
kpis: [
{ id: "goal", label: "Goal completion", value: 79, unit: "%", decimals: 0, ring: 79, delta: 12.4, goodWhenDown: false, caption: "of monthly target", color: INDIGO },
{ id: "conv", label: "Conversion rate", value: 5.6, unit: "%", decimals: 1, ring: 80, delta: 1.4, goodWhenDown: false, caption: "against a 7% goal", color: VIOLET },
{ id: "bounce", label: "Bounce rate", value: 36.1, unit: "%", decimals: 1, ring: 36.1, delta: -3.8, goodWhenDown: true, caption: "sessions with no action", color: AMBER },
{ id: "return", label: "Returning visitors", value: 61, unit: "%", decimals: 0, ring: 61, delta: 4.9, goodWhenDown: false, caption: "of all visitors", color: EMERALD },
],
},
};
const ALL_IDS = ["organic", "direct", "referral", "social", "email"];
const EASE: [number, number, number, number] = [0.22, 1, 0.36, 1];
/* ------------------------------ number helper ----------------------------- */
function fmt(n: number, decimals: number): string {
return n.toLocaleString("en-US", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
}
function AnimatedNumber({
value,
decimals = 0,
reduced,
}: {
value: number;
decimals?: number;
reduced: boolean;
}): ReactNode {
const [display, setDisplay] = useState<number>(reduced ? value : 0);
const from = useRef<number>(reduced ? value : 0);
useEffect(() => {
if (reduced) {
from.current = value;
setDisplay(value);
return;
}
const start = from.current;
const delta = value - start;
const duration = 850;
let raf = 0;
let t0 = 0;
const ease = (x: number): number => 1 - Math.pow(1 - x, 3);
const step = (ts: number): void => {
if (t0 === 0) t0 = ts;
const p = Math.min((ts - t0) / duration, 1);
const cur = start + delta * ease(p);
from.current = cur;
setDisplay(cur);
if (p < 1) raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [value, reduced]);
return <>{fmt(display, decimals)}</>;
}
/* --------------------------------- arrows --------------------------------- */
function DeltaIcon({ up }: { up: boolean }): ReactNode {
return (
<svg viewBox="0 0 12 12" width="12" height="12" aria-hidden="true" className="shrink-0">
<path
d={up ? "M6 2.5 10 8H2z" : "M6 9.5 2 4h8z"}
fill="currentColor"
/>
</svg>
);
}
/* -------------------------------- KPI card -------------------------------- */
function KpiCard({
kpi,
index,
reduced,
}: {
kpi: Kpi;
index: number;
reduced: boolean;
}): ReactNode {
const finalDash = `${kpi.ring} ${100 - kpi.ring}`;
const good = kpi.goodWhenDown ? kpi.delta < 0 : kpi.delta > 0;
const deltaUp = kpi.delta > 0;
return (
<div className="group relative flex flex-col rounded-2xl border border-slate-200 bg-white/80 p-5 backdrop-blur transition-shadow hover:shadow-lg hover:shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900/70 dark:hover:shadow-black/40">
<div className="flex items-start justify-between gap-3">
<span className="text-[0.8rem] font-medium leading-tight text-slate-500 dark:text-slate-400">
{kpi.label}
</span>
<span
className={[
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.7rem] font-semibold tabular-nums",
good
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "bg-rose-500/10 text-rose-600 dark:text-rose-400",
].join(" ")}
>
<DeltaIcon up={deltaUp} />
{deltaUp ? "+" : ""}
{fmt(kpi.delta, 1)}
</span>
</div>
<div className="mt-3 flex items-center gap-4">
<div className="relative h-[76px] w-[76px] shrink-0">
<svg viewBox="0 0 100 100" className="h-full w-full -rotate-90">
<circle
cx="50"
cy="50"
r="40"
fill="none"
strokeWidth="9"
className="stroke-slate-200/80 dark:stroke-slate-700/70"
/>
<motion.circle
cx="50"
cy="50"
r="40"
fill="none"
strokeWidth="9"
strokeLinecap="round"
pathLength={100}
className={kpi.color.stroke}
initial={{ strokeDasharray: reduced ? finalDash : "0 100" }}
animate={{ strokeDasharray: finalDash }}
transition={{
duration: reduced ? 0 : 0.9,
ease: EASE,
delay: reduced ? 0 : 0.15 + index * 0.08,
}}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-lg font-semibold tabular-nums text-slate-900 dark:text-slate-50">
<AnimatedNumber value={kpi.value} decimals={kpi.decimals} reduced={reduced} />
<span className="text-[0.7em] text-slate-400 dark:text-slate-500">{kpi.unit}</span>
</span>
</div>
</div>
<p className="text-[0.78rem] leading-snug text-slate-500 dark:text-slate-400">
{kpi.caption}
</p>
</div>
</div>
);
}
/* ------------------------------ main component ----------------------------- */
export default function StatxDonut(): ReactNode {
const prefersReduced = useReducedMotion();
const reduced = prefersReduced === true;
const uid = useId();
const [range, setRange] = useState<RangeId>("30d");
const [activeIds, setActiveIds] = useState<string[]>(ALL_IDS);
const [hovered, setHovered] = useState<string | null>(null);
const data = DATA[range];
const sources = data.sources;
const active = sources.filter((s) => activeIds.includes(s.id));
const total = active.reduce((sum, s) => sum + s.value, 0);
function toggle(id: string): void {
setActiveIds((prev) => {
const on = prev.includes(id);
if (on) {
if (prev.length === 1) return prev; // keep at least one segment
return prev.filter((x) => x !== id);
}
return ALL_IDS.filter((x) => x === id || prev.includes(x));
});
}
// Build arc geometry (pathLength normalised to 100).
let cum = 0;
const gap = active.length > 1 ? 1.6 : 0;
const arcs = sources.map((s) => {
const isActive = activeIds.includes(s.id);
const pct = isActive && total > 0 ? (s.value / total) * 100 : 0;
const visible = Math.max(pct - gap, 0);
const offset = -cum;
if (isActive) cum += pct;
return { source: s, isActive, pct, visible, offset };
});
const hoveredSource = hovered ? sources.find((s) => s.id === hovered) ?? null : null;
const hoveredActive = hoveredSource ? activeIds.includes(hoveredSource.id) : false;
const hoveredPct =
hoveredSource && hoveredActive && total > 0 ? (hoveredSource.value / total) * 100 : 0;
const arcTransition = {
strokeDasharray: { duration: reduced ? 0 : 0.7, ease: EASE },
strokeDashoffset: { duration: reduced ? 0 : 0.7, ease: EASE },
opacity: { duration: reduced ? 0 : 0.25 },
strokeWidth: { duration: reduced ? 0 : 0.25 },
};
return (
<section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-white px-4 py-16 text-slate-900 sm:px-6 md:py-24 dark:from-slate-950 dark:to-slate-900 dark:text-slate-50">
<style>{`
@keyframes statxdonut-orbit {
to { transform: rotate(360deg); }
}
@keyframes statxdonut-pulse {
0%, 100% { opacity: 0.55; transform: scale(1); }
50% { opacity: 1; transform: scale(1.35); }
}
.statxdonut-orbit {
transform-origin: 50% 50%;
animation: statxdonut-orbit 26s linear infinite;
}
.statxdonut-dot {
animation: statxdonut-pulse 2.4s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.statxdonut-orbit, .statxdonut-dot { animation: none !important; }
}
`}</style>
{/* atmospheric blobs */}
<div
aria-hidden="true"
className="pointer-events-none absolute -left-24 top-10 h-72 w-72 rounded-full bg-indigo-400/20 blur-3xl dark:bg-indigo-600/15"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute -right-16 bottom-0 h-72 w-72 rounded-full bg-violet-400/15 blur-3xl dark:bg-violet-700/15"
/>
<div className="relative mx-auto max-w-6xl">
{/* ------------------------------- header ------------------------------ */}
<header className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
<div>
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-3 py-1 text-[0.72rem] font-semibold uppercase tracking-[0.14em] text-slate-500 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-400">
<span className="relative flex h-1.5 w-1.5">
<span className="statxdonut-dot absolute inline-flex h-full w-full rounded-full bg-emerald-500" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
</span>
Acquisition report
</span>
<h2 className="mt-3 text-2xl font-semibold tracking-tight sm:text-3xl">
Audience overview
</h2>
<p className="mt-1.5 max-w-md text-sm text-slate-500 dark:text-slate-400">
Sessions by channel, with goal and engagement signals. Toggle a channel to
recompute the mix.
</p>
</div>
{/* time-range radiogroup */}
<div
role="radiogroup"
aria-label="Select reporting window"
className="inline-flex shrink-0 rounded-full border border-slate-200 bg-white/70 p-1 shadow-sm dark:border-slate-700 dark:bg-slate-800/60"
>
{RANGES.map((r) => {
const on = range === r.id;
return (
<label
key={r.id}
className={[
"relative cursor-pointer select-none rounded-full px-4 py-1.5 text-sm font-semibold transition-colors",
"peer-focus-visible:outline-none",
on
? "text-white"
: "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-100",
].join(" ")}
>
<input
type="radio"
name={`${uid}-range`}
value={r.id}
checked={on}
onChange={() => setRange(r.id)}
className="peer sr-only"
aria-label={r.full}
/>
{on && (
<motion.span
layoutId={`${uid}-pill`}
className="absolute inset-0 -z-10 rounded-full bg-indigo-600 dark:bg-indigo-500"
transition={{ duration: reduced ? 0 : 0.3, ease: EASE }}
/>
)}
<span className="pointer-events-none absolute inset-0 rounded-full ring-indigo-500 peer-focus-visible:ring-2 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white dark:peer-focus-visible:ring-offset-slate-900" />
{r.label}
</label>
);
})}
</div>
</header>
{/* --------------------------- main donut card -------------------------- */}
<div className="mt-8 grid gap-6 rounded-3xl border border-slate-200 bg-white/70 p-6 shadow-sm backdrop-blur md:grid-cols-2 md:p-8 dark:border-slate-800 dark:bg-slate-900/60">
{/* donut */}
<div className="flex items-center justify-center">
<div className="relative aspect-square w-full max-w-[280px]">
<svg
viewBox="0 0 100 100"
className="h-full w-full"
role="img"
aria-label={`Donut chart of traffic sources. ${fmt(total, 0)} total sessions across ${active.length} active ${active.length === 1 ? "channel" : "channels"}.`}
>
{/* decorative orbiting ticks */}
<g className="statxdonut-orbit" aria-hidden="true">
<circle
cx="50"
cy="50"
r="46"
fill="none"
strokeWidth="0.6"
strokeDasharray="0.4 3.2"
strokeLinecap="round"
className="stroke-slate-300 dark:stroke-slate-700"
/>
</g>
<g transform="rotate(-90 50 50)">
{/* track */}
<circle
cx="50"
cy="50"
r="38"
fill="none"
strokeWidth="11"
className="stroke-slate-200/70 dark:stroke-slate-800"
/>
{arcs.map((a) => {
const dimmed = hovered !== null && hovered !== a.source.id;
return (
<motion.circle
key={a.source.id}
cx="50"
cy="50"
r="38"
fill="none"
strokeLinecap="butt"
pathLength={100}
className={a.source.color.stroke}
style={{ display: a.isActive ? undefined : "none" }}
initial={false}
animate={{
strokeDasharray: `${a.visible} ${100 - a.visible}`,
strokeDashoffset: a.offset,
opacity: dimmed ? 0.28 : 1,
strokeWidth: hovered === a.source.id ? 14 : 11,
}}
transition={arcTransition}
/>
);
})}
</g>
</svg>
{/* center readout */}
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center">
<AnimatePresence mode="wait">
{hoveredSource ? (
<motion.div
key={`h-${hoveredSource.id}`}
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={{ duration: reduced ? 0 : 0.18 }}
className="px-6"
>
<span className={`text-[0.72rem] font-semibold uppercase tracking-wide ${hoveredSource.color.text}`}>
{hoveredSource.label}
</span>
<p className="mt-0.5 text-2xl font-semibold tabular-nums text-slate-900 dark:text-slate-50">
{fmt(hoveredSource.value, 0)}
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
{hoveredActive ? `${hoveredPct.toFixed(1)}% of mix` : "Hidden from chart"}
</p>
</motion.div>
) : (
<motion.div
key="total"
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={{ duration: reduced ? 0 : 0.18 }}
>
<p className="text-3xl font-semibold tracking-tight tabular-nums text-slate-900 sm:text-4xl dark:text-slate-50">
<AnimatedNumber value={total} reduced={reduced} />
</p>
<p className="mt-1 text-[0.72rem] font-medium uppercase tracking-[0.12em] text-slate-500 dark:text-slate-400">
Total sessions
</p>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
{/* legend / controls */}
<div className="flex flex-col justify-center">
<div className="mb-3 flex items-baseline justify-between">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200">
Traffic by channel
</h3>
<span className="text-[0.72rem] text-slate-400 dark:text-slate-500">
{RANGES.find((r) => r.id === range)?.full}
</span>
</div>
<ul className="flex flex-col gap-1">
{sources.map((s) => {
const isActive = activeIds.includes(s.id);
const pct = isActive && total > 0 ? (s.value / total) * 100 : 0;
const isLast = isActive && active.length === 1;
return (
<li key={s.id}>
<button
type="button"
aria-pressed={isActive}
aria-disabled={isLast}
title={isLast ? "Keep at least one channel active" : undefined}
onClick={() => toggle(s.id)}
onMouseEnter={() => setHovered(s.id)}
onMouseLeave={() => setHovered((h) => (h === s.id ? null : h))}
onFocus={() => setHovered(s.id)}
onBlur={() => setHovered((h) => (h === s.id ? null : h))}
className={[
"group flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900",
"hover:bg-slate-100/80 dark:hover:bg-slate-800/60",
isLast ? "cursor-not-allowed" : "cursor-pointer",
].join(" ")}
>
<span
className={[
"flex h-4 w-4 shrink-0 items-center justify-center rounded-[5px] border-2 transition-all",
isActive
? `${s.color.bg} border-transparent`
: "border-slate-300 bg-transparent dark:border-slate-600",
].join(" ")}
aria-hidden="true"
>
{isActive && (
<svg viewBox="0 0 10 10" width="9" height="9" className="text-white">
<path
d="M1.5 5.2 4 7.5 8.5 2.5"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center justify-between gap-2">
<span
className={[
"truncate text-sm font-medium transition-colors",
isActive
? "text-slate-800 dark:text-slate-100"
: "text-slate-400 line-through dark:text-slate-500",
].join(" ")}
>
{s.label}
</span>
<span className="shrink-0 text-sm font-semibold tabular-nums text-slate-700 dark:text-slate-200">
{fmt(s.value, 0)}
</span>
</span>
<span className="mt-1.5 flex items-center gap-2">
<span className="relative h-1.5 flex-1 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
<motion.span
className={`absolute inset-y-0 left-0 rounded-full ${s.color.bg}`}
initial={false}
animate={{ width: `${pct}%`, opacity: isActive ? 1 : 0.3 }}
transition={{ duration: reduced ? 0 : 0.6, ease: EASE }}
/>
</span>
<span className="w-10 shrink-0 text-right text-[0.72rem] tabular-nums text-slate-400 dark:text-slate-500">
{isActive ? `${pct.toFixed(1)}%` : "—"}
</span>
</span>
</span>
</button>
</li>
);
})}
</ul>
<p className="mt-3 px-3 text-[0.72rem] leading-relaxed text-slate-400 dark:text-slate-500">
Percentages reflect the active mix. Keyboard: Tab to a channel, Space to toggle it
in or out of the chart.
</p>
</div>
</div>
{/* ------------------------------ KPI grid ----------------------------- */}
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{data.kpis.map((kpi, i) => (
<KpiCard key={kpi.id} kpi={kpi} index={i} reduced={reduced} />
))}
</div>
</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.

Counter Row Stat
Originalanimated counter stats row

Cards Stat
Originalstat cards with icons

Gradient Stat
Originalgradient stats band

Split Stat
Originalstats beside copy
Icons Stat
Originalicon stat grid

