Chart Donut
Original · freeSVG donut chart with centre label
byWeb InnoventixReact + Tailwind
chartdonutcharts
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/chart-donut.jsonchart-donut.tsx
"use client";
import { useId, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { motion, useReducedMotion } from "motion/react";
type Mode = "amount" | "share";
const SEGMENTS = [
{ id: "compute", label: "Compute", value: 20240, change: 6.2, stroke: "stroke-indigo-500 dark:stroke-indigo-400", dot: "bg-indigo-500 dark:bg-indigo-400" },
{ id: "storage", label: "Storage", value: 8680, change: 2.1, stroke: "stroke-violet-500 dark:stroke-violet-400", dot: "bg-violet-500 dark:bg-violet-400" },
{ id: "database", label: "Database", value: 7230, change: -3.4, stroke: "stroke-sky-500 dark:stroke-sky-400", dot: "bg-sky-500 dark:bg-sky-400" },
{ id: "network", label: "Networking", value: 5780, change: 11.8, stroke: "stroke-emerald-500 dark:stroke-emerald-400", dot: "bg-emerald-500 dark:bg-emerald-400" },
{ id: "analytics", label: "Analytics", value: 3860, change: 0.9, stroke: "stroke-amber-500 dark:stroke-amber-400", dot: "bg-amber-500 dark:bg-amber-400" },
{ id: "monitoring", label: "Monitoring", value: 2410, change: -5.0, stroke: "stroke-rose-500 dark:stroke-rose-400", dot: "bg-rose-500 dark:bg-rose-400" },
] as const;
interface Arc {
id: string;
label: string;
value: number;
change: number;
stroke: string;
dot: string;
fraction: number;
startAngle: number;
dash: number;
}
const TOTAL = SEGMENTS.reduce((sum, s) => sum + s.value, 0);
const CX = 100;
const CY = 100;
const R = 70;
const CIRC = 2 * Math.PI * R;
const GAP = 3;
const BASE_W = 22;
const ACTIVE_W = 30;
const currency = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
});
function sharePct(value: number): string {
return `${((value / TOTAL) * 100).toFixed(1)}%`;
}
function legendValue(value: number, mode: Mode): string {
return mode === "amount" ? currency.format(value) : sharePct(value);
}
function Trend({ change }: { change: number }) {
const up = change >= 0;
return (
<span
className={`inline-flex items-center gap-0.5 text-[11px] font-semibold tabular-nums ${
up ? "text-emerald-600 dark:text-emerald-400" : "text-rose-600 dark:text-rose-400"
}`}
>
<svg viewBox="0 0 12 12" className="h-2.5 w-2.5" aria-hidden="true">
<path d={up ? "M6 2.5 10 8 2 8Z" : "M6 9.5 2 4 10 4Z"} fill="currentColor" />
</svg>
{`${up ? "+" : ""}${change.toFixed(1)}%`}
</span>
);
}
export default function ChartDonut() {
const prefersReduced = useReducedMotion();
const reduce = prefersReduced === true;
const [mode, setMode] = useState<Mode>("amount");
const [selected, setSelected] = useState<string | null>(null);
const [hovered, setHovered] = useState<string | null>(null);
const [roving, setRoving] = useState(0);
const headingId = useId();
const groupId = useId();
const btnRefs = useRef<Array<HTMLButtonElement | null>>([]);
const activeId = hovered ?? selected;
const arcs = useMemo<Arc[]>(() => {
let acc = 0;
return SEGMENTS.map((seg) => {
const fraction = seg.value / TOTAL;
const startAngle = -90 + acc * 360;
const dash = Math.max(fraction * CIRC - GAP, 0.001);
acc += fraction;
return { ...seg, fraction, startAngle, dash };
});
}, []);
const active = arcs.find((a) => a.id === activeId) ?? null;
const summary =
`Donut chart of Q2 monthly cloud spend, ${currency.format(TOTAL)} total across ${arcs.length} services: ` +
arcs.map((a) => `${a.label} ${sharePct(a.value)}`).join(", ") +
".";
const centerLabel = active ? active.label : "Total monthly spend";
const centerPrimary = active ? legendValue(active.value, mode) : currency.format(TOTAL);
const centerSecondary = active
? mode === "amount"
? `${sharePct(active.value)} of spend`
: currency.format(active.value)
: `Across ${arcs.length} services`;
function toggle(id: string) {
setSelected((prev) => (prev === id ? null : id));
}
function moveFocus(next: number) {
const n = (next + arcs.length) % arcs.length;
setRoving(n);
btnRefs.current[n]?.focus();
}
function handleKey(e: KeyboardEvent<HTMLButtonElement>, i: number) {
switch (e.key) {
case "ArrowDown":
case "ArrowRight":
e.preventDefault();
moveFocus(i + 1);
break;
case "ArrowUp":
case "ArrowLeft":
e.preventDefault();
moveFocus(i - 1);
break;
case "Home":
e.preventDefault();
moveFocus(0);
break;
case "End":
e.preventDefault();
moveFocus(arcs.length - 1);
break;
}
}
const ring =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:focus-visible:ring-indigo-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950";
return (
<section
aria-labelledby={headingId}
className="relative w-full overflow-hidden bg-white px-6 py-20 dark:bg-slate-950 sm:py-28"
>
<style>{`
@keyframes donutcd_pop {
0% { opacity: 0; transform: translateY(4px) scale(0.96); }
100% { opacity: 1; transform: none; }
}
@keyframes donutcd_rise {
0% { opacity: 0; transform: translateY(10px); }
100% { opacity: 1; transform: none; }
}
.donutcd_pop { animation: donutcd_pop 420ms cubic-bezier(0.22, 1, 0.36, 1); }
.donutcd_rise { animation: donutcd_rise 640ms cubic-bezier(0.22, 1, 0.36, 1) both; }
@media (prefers-reduced-motion: reduce) {
.donutcd_pop, .donutcd_rise { animation: none !important; }
}
`}</style>
<div
aria-hidden="true"
className="pointer-events-none absolute -top-24 right-0 h-72 w-72 rounded-full bg-indigo-200/40 blur-3xl dark:bg-indigo-500/10"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute -bottom-24 left-0 h-72 w-72 rounded-full bg-violet-200/40 blur-3xl dark:bg-violet-500/10"
/>
<div className="donutcd_rise relative mx-auto max-w-5xl">
<div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
<div className="max-w-xl">
<span className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
Infrastructure · Q2 2026
</span>
<h2
id={headingId}
className="mt-3 text-3xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-4xl"
>
Where the cloud budget goes
</h2>
<p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-300">
Monthly spend across six services. Select a slice to break it down, or switch
between dollars and share of total.
</p>
</div>
<div
role="group"
aria-label="Value display mode"
className="inline-flex shrink-0 rounded-full border border-slate-200 bg-slate-100 p-1 dark:border-slate-800 dark:bg-slate-900"
>
{(["amount", "share"] as const).map((m) => {
const on = mode === m;
return (
<button
key={m}
type="button"
aria-pressed={on}
onClick={() => setMode(m)}
className={`rounded-full px-4 py-1.5 text-sm font-medium transition-colors ${ring} ${
on
? "bg-white text-slate-900 shadow-sm dark:bg-slate-700 dark:text-white"
: "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200"
}`}
>
{m === "amount" ? "Amount" : "Share"}
</button>
);
})}
</div>
</div>
<div className="mt-12 grid items-center gap-10 lg:grid-cols-[minmax(0,320px)_1fr] lg:gap-16">
<div className="relative mx-auto aspect-square w-full max-w-[300px]">
<svg viewBox="0 0 200 200" role="img" aria-label={summary} className="h-full w-full">
<circle
cx={CX}
cy={CY}
r={R}
fill="none"
strokeWidth={BASE_W}
className="stroke-slate-200 dark:stroke-slate-800"
/>
{arcs.map((a, i) => {
const isActive = activeId === a.id;
const dim = activeId !== null && !isActive;
return (
<g key={a.id} transform={`rotate(${a.startAngle} ${CX} ${CY})`}>
<motion.circle
cx={CX}
cy={CY}
r={R}
fill="none"
strokeLinecap="round"
className={`${a.stroke} cursor-pointer`}
initial={reduce ? false : { strokeDasharray: `0 ${CIRC}`, strokeWidth: BASE_W }}
animate={{
strokeDasharray: `${a.dash} ${CIRC - a.dash}`,
strokeWidth: isActive ? ACTIVE_W : BASE_W,
opacity: dim ? 0.28 : 1,
}}
transition={{
strokeDasharray: {
duration: reduce ? 0 : 1,
delay: reduce ? 0 : i * 0.09,
ease: [0.22, 1, 0.36, 1],
},
strokeWidth: { duration: reduce ? 0 : 0.28 },
opacity: { duration: reduce ? 0 : 0.28 },
}}
onPointerEnter={() => setHovered(a.id)}
onPointerLeave={() => setHovered(null)}
onClick={() => toggle(a.id)}
/>
</g>
);
})}
</svg>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center px-10 text-center"
>
<span
className={`mb-2 h-1.5 w-6 rounded-full transition-colors ${
active ? active.dot : "bg-slate-300 dark:bg-slate-700"
}`}
/>
<span className="text-[11px] font-medium uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400">
{centerLabel}
</span>
<span
key={`${activeId ?? "total"}-${mode}`}
className="donutcd_pop mt-1 text-3xl font-semibold tracking-tight tabular-nums text-slate-900 dark:text-white sm:text-4xl"
>
{centerPrimary}
</span>
<span className="mt-1 text-xs text-slate-500 dark:text-slate-400">
{centerSecondary}
</span>
</div>
</div>
<div>
<ul
role="list"
aria-label="Spend by service — select a slice to highlight it"
className="flex flex-col gap-1.5"
>
{arcs.map((a, i) => {
const on = selected === a.id;
return (
<li key={a.id}>
<button
ref={(el) => {
btnRefs.current[i] = el;
}}
type="button"
aria-pressed={on}
tabIndex={i === roving ? 0 : -1}
onClick={() => toggle(a.id)}
onFocus={() => setRoving(i)}
onKeyDown={(e) => handleKey(e, i)}
onPointerEnter={() => setHovered(a.id)}
onPointerLeave={() => setHovered(null)}
className={`flex w-full items-center gap-3 rounded-xl border px-4 py-3 text-left transition-colors ${ring} ${
on
? "border-slate-300 bg-slate-50 dark:border-slate-700 dark:bg-slate-900"
: "border-transparent hover:bg-slate-50 dark:hover:bg-slate-900/60"
}`}
>
<span className={`h-3 w-3 shrink-0 rounded-full ${a.dot}`} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-slate-800 dark:text-slate-100">
{a.label}
</span>
<span className="mt-0.5 block">
<Trend change={a.change} />
<span className="ml-2 text-[11px] text-slate-400 dark:text-slate-500">
vs Q1
</span>
</span>
</span>
<span className="shrink-0 text-right">
<span className="block text-sm font-semibold tabular-nums text-slate-900 dark:text-white">
{legendValue(a.value, mode)}
</span>
<span className="mt-0.5 block text-[11px] text-slate-400 dark:text-slate-500 tabular-nums">
{mode === "amount" ? sharePct(a.value) : currency.format(a.value)}
</span>
</span>
</button>
</li>
);
})}
</ul>
<div className="mt-4 flex items-center justify-between border-t border-slate-200 px-4 pt-4 dark:border-slate-800">
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">
Total
</span>
<span className="text-base font-semibold tabular-nums text-slate-900 dark:text-white">
{currency.format(TOTAL)}
<span className="ml-1 text-sm font-normal text-slate-400 dark:text-slate-500">
/mo
</span>
</span>
</div>
</div>
</div>
</div>
</section>
);
}Dependencies
motion
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 →
Chart Bar
Originalhand-built SVG/div bar chart with axes

Chart Bar Grouped
Originalgrouped bar chart with legend

Chart Line
OriginalSVG line chart with points and grid

Chart Area
OriginalSVG area chart with gradient fill

Chart Pie
OriginalSVG pie chart with legend

Chart Radial
Originalradial progress bars chart

Chart Sparkline
Originalinline sparklines in stat rows

Chart Gauge
Originalsemicircle gauge chart

Chart Stacked Bar
Originalstacked bar chart

Chart Progress
Originalhorizontal progress/metric chart

Chart Heatmap
Originalcontribution-style heatmap grid

Chart Bubble
OriginalSVG bubble/scatter chart

