Chart Pie
Original · freeSVG pie chart with legend
byWeb InnoventixReact + Tailwind
chartpiecharts
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-pie.jsonchart-pie.tsx
"use client";
import { useId, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { motion, useReducedMotion } from "motion/react";
type Channel = {
id: string;
label: string;
hint: string;
now: number;
prev: number;
fill: string;
swatch: string;
ring: string;
};
const CHANNELS: Channel[] = [
{
id: "organic",
label: "Organic search",
hint: "Unpaid Google & Bing results",
now: 48210,
prev: 44120,
fill: "text-emerald-500 dark:text-emerald-400",
swatch: "bg-emerald-500 dark:bg-emerald-400",
ring: "focus-visible:ring-emerald-500",
},
{
id: "direct",
label: "Direct",
hint: "Typed URL or bookmark",
now: 22940,
prev: 21050,
fill: "text-indigo-500 dark:text-indigo-400",
swatch: "bg-indigo-500 dark:bg-indigo-400",
ring: "focus-visible:ring-indigo-500",
},
{
id: "referral",
label: "Referral",
hint: "Links from other sites",
now: 12680,
prev: 14300,
fill: "text-sky-500 dark:text-sky-400",
swatch: "bg-sky-500 dark:bg-sky-400",
ring: "focus-visible:ring-sky-500",
},
{
id: "social",
label: "Social",
hint: "LinkedIn, X and Reddit",
now: 9430,
prev: 7980,
fill: "text-violet-500 dark:text-violet-400",
swatch: "bg-violet-500 dark:bg-violet-400",
ring: "focus-visible:ring-violet-500",
},
{
id: "email",
label: "Email",
hint: "Newsletter & lifecycle",
now: 6120,
prev: 5640,
fill: "text-amber-500 dark:text-amber-400",
swatch: "bg-amber-500 dark:bg-amber-400",
ring: "focus-visible:ring-amber-500",
},
{
id: "paid",
label: "Paid ads",
hint: "Google & Meta campaigns",
now: 4560,
prev: 6210,
fill: "text-rose-500 dark:text-rose-400",
swatch: "bg-rose-500 dark:bg-rose-400",
ring: "focus-visible:ring-rose-500",
},
];
const PERIODS = [
{ id: "this", label: "This month" },
{ id: "last", label: "Last month" },
] as const;
type PeriodId = (typeof PERIODS)[number]["id"];
const CX = 120;
const CY = 120;
const R_OUT = 100;
const R_IN = 62;
const GAP = 2.4;
const nf = new Intl.NumberFormat("en-US");
function amount(c: Channel, period: PeriodId): number {
return period === "this" ? c.now : c.prev;
}
function pointOnCircle(r: number, deg: number): { x: number; y: number } {
const rad = ((deg - 90) * Math.PI) / 180;
return { x: CX + r * Math.cos(rad), y: CY + r * Math.sin(rad) };
}
function arcPath(startDeg: number, endDeg: number): string {
const large = endDeg - startDeg > 180 ? 1 : 0;
const o1 = pointOnCircle(R_OUT, startDeg);
const o2 = pointOnCircle(R_OUT, endDeg);
const i2 = pointOnCircle(R_IN, endDeg);
const i1 = pointOnCircle(R_IN, startDeg);
return [
`M ${o1.x.toFixed(3)} ${o1.y.toFixed(3)}`,
`A ${R_OUT} ${R_OUT} 0 ${large} 1 ${o2.x.toFixed(3)} ${o2.y.toFixed(3)}`,
`L ${i2.x.toFixed(3)} ${i2.y.toFixed(3)}`,
`A ${R_IN} ${R_IN} 0 ${large} 0 ${i1.x.toFixed(3)} ${i1.y.toFixed(3)}`,
"Z",
].join(" ");
}
function fullRingPath(): string {
return [
`M ${CX - R_OUT} ${CY}`,
`a ${R_OUT} ${R_OUT} 0 1 0 ${R_OUT * 2} 0`,
`a ${R_OUT} ${R_OUT} 0 1 0 ${-R_OUT * 2} 0`,
"Z",
`M ${CX - R_IN} ${CY}`,
`a ${R_IN} ${R_IN} 0 1 1 ${R_IN * 2} 0`,
`a ${R_IN} ${R_IN} 0 1 1 ${-R_IN * 2} 0`,
"Z",
].join(" ");
}
type Segment = {
channel: Channel;
value: number;
frac: number;
d: string;
dx: number;
dy: number;
};
export default function ChartPie() {
const reduce = useReducedMotion();
const uid = useId();
const [period, setPeriod] = useState<PeriodId>("this");
const [hidden, setHidden] = useState<Set<string>>(() => new Set());
const [active, setActive] = useState<string | null>(null);
const radioRefs = useRef<(HTMLButtonElement | null)[]>([]);
const visibleCount = CHANNELS.length - hidden.size;
const { segments, total } = useMemo(() => {
const vis = CHANNELS.filter((c) => !hidden.has(c.id));
const sum = vis.reduce((s, c) => s + amount(c, period), 0);
if (vis.length === 0 || sum === 0) return { segments: [] as Segment[], total: 0 };
let cursor = 0;
const list: Segment[] = vis.map((c) => {
const value = amount(c, period);
const frac = value / sum;
const sweep = frac * 360;
const start = cursor;
const end = cursor + sweep;
cursor = end;
const g = sweep > GAP * 3 ? GAP / 2 : 0;
const d = vis.length === 1 ? fullRingPath() : arcPath(start + g, end - g);
const mid = (start + end) / 2;
const rad = ((mid - 90) * Math.PI) / 180;
return { channel: c, value, frac, d, dx: Math.cos(rad), dy: Math.sin(rad) };
});
return { segments: list, total: sum };
}, [hidden, period]);
const shareById = useMemo(() => {
const m = new Map<string, number>();
segments.forEach((s) => m.set(s.channel.id, s.frac));
return m;
}, [segments]);
const activeSeg = active ? segments.find((s) => s.channel.id === active) ?? null : null;
const periodLabel = PERIODS.find((p) => p.id === period)?.label ?? "";
const chartSummary = `Donut chart of website sessions by channel for ${periodLabel.toLowerCase()}. Total ${nf.format(
total,
)} sessions across ${visibleCount} of ${CHANNELS.length} channels.`;
function toggleHidden(id: string) {
setHidden((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
setActive((a) => (a === id ? null : a));
}
function onRadioKey(e: KeyboardEvent<HTMLButtonElement>, idx: number) {
const keys = ["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp"];
if (!keys.includes(e.key)) return;
e.preventDefault();
const dir = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : -1;
const next = (idx + dir + PERIODS.length) % PERIODS.length;
setPeriod(PERIODS[next].id);
radioRefs.current[next]?.focus();
}
const activeDelta = activeSeg
? activeSeg.channel.prev > 0
? ((activeSeg.channel.now - activeSeg.channel.prev) / activeSeg.channel.prev) * 100
: 0
: 0;
const activeUp = activeDelta >= 0;
return (
<section className="relative w-full overflow-hidden bg-white px-4 py-16 text-zinc-900 sm:px-6 sm:py-24 dark:bg-zinc-950 dark:text-zinc-50">
<style>{`
@keyframes chartpie-rise {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes chartpie-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.chartpie-anim, .chartpie-spin { animation: none !important; }
}
`}</style>
<div className="mx-auto max-w-5xl">
<div className="chartpie-anim [animation:chartpie-rise_0.6s_ease-out_both] max-w-2xl">
<span className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-zinc-50 px-3 py-1 text-xs font-medium tracking-wide text-zinc-600 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 dark:bg-emerald-400" />
Northwind Analytics
</span>
<h2 className="mt-4 text-2xl font-semibold tracking-tight sm:text-3xl">
Where your traffic comes from
</h2>
<p className="mt-2 text-sm leading-relaxed text-zinc-600 sm:text-base dark:text-zinc-400">
Sessions on northwind.app broken down by acquisition channel. Switch the period,
hover a slice, or hide a channel to watch the mix recalculate.
</p>
</div>
<div
className="chartpie-anim mt-8 grid grid-cols-1 items-center gap-8 rounded-3xl border border-zinc-200 bg-zinc-50/60 p-5 sm:p-8 lg:grid-cols-2 lg:gap-12 dark:border-zinc-800 dark:bg-zinc-900/40"
style={{ animation: "chartpie-rise 0.7s ease-out 0.08s both" }}
>
{/* Chart */}
<div className="flex flex-col items-center">
<div className="relative mx-auto aspect-square w-full max-w-[320px]">
<svg
viewBox="0 0 240 240"
className="h-full w-full"
role="img"
aria-label={chartSummary}
>
{/* decorative dashed guide ring */}
<circle
cx={CX}
cy={CY}
r={112}
fill="none"
strokeWidth={1}
strokeDasharray="2 9"
className="chartpie-spin text-zinc-300 [animation:chartpie-spin_48s_linear_infinite] dark:text-zinc-700"
stroke="currentColor"
style={{ transformBox: "fill-box", transformOrigin: "center" }}
aria-hidden="true"
/>
{/* static track */}
<circle
cx={CX}
cy={CY}
r={(R_OUT + R_IN) / 2}
fill="none"
strokeWidth={R_OUT - R_IN}
className="text-zinc-100 dark:text-zinc-800/70"
stroke="currentColor"
aria-hidden="true"
/>
<motion.g
aria-hidden="true"
style={{ transformBox: "fill-box", transformOrigin: "center" }}
initial={reduce ? false : { rotate: -8, scale: 0.94 }}
animate={{ rotate: 0, scale: 1 }}
transition={reduce ? { duration: 0 } : { duration: 0.7, ease: [0.16, 1, 0.3, 1] }}
>
{segments.map((seg, i) => {
const dimmed = active !== null && active !== seg.channel.id;
const isActive = active === seg.channel.id;
const off = isActive ? 7 : 0;
return (
<motion.path
key={seg.channel.id}
d={seg.d}
fill="currentColor"
fillRule="evenodd"
className={`${seg.channel.fill} cursor-pointer outline-none`}
initial={reduce ? false : { opacity: 0 }}
animate={{
opacity: dimmed ? 0.32 : 1,
x: seg.dx * off,
y: seg.dy * off,
}}
transition={
reduce
? { duration: 0 }
: {
opacity: { duration: 0.5, delay: i * 0.06 },
x: { type: "spring", stiffness: 260, damping: 22 },
y: { type: "spring", stiffness: 260, damping: 22 },
}
}
onMouseEnter={() => setActive(seg.channel.id)}
onMouseLeave={() => setActive(null)}
/>
);
})}
</motion.g>
</svg>
{/* centre readout */}
<div className="pointer-events-none absolute inset-0 grid place-items-center px-10 text-center">
{activeSeg ? (
<div>
<p className="text-[0.65rem] font-medium uppercase tracking-widest text-zinc-500 dark:text-zinc-400">
{activeSeg.channel.label}
</p>
<p className="mt-1 text-3xl font-semibold tabular-nums tracking-tight">
{nf.format(activeSeg.value)}
</p>
<p className="mt-0.5 text-xs font-medium text-zinc-500 dark:text-zinc-400">
{(activeSeg.frac * 100).toFixed(1)}% of total
</p>
{period === "this" && (
<p
className={`mt-1.5 inline-flex items-center gap-1 text-xs font-semibold ${
activeUp
? "text-emerald-600 dark:text-emerald-400"
: "text-rose-600 dark:text-rose-400"
}`}
>
<svg viewBox="0 0 12 12" className="h-3 w-3" aria-hidden="true">
<path
d={activeUp ? "M6 3 L10 8 L2 8 Z" : "M6 9 L2 4 L10 4 Z"}
fill="currentColor"
/>
</svg>
{activeUp ? "+" : "−"}
{Math.abs(activeDelta).toFixed(1)}% MoM
</p>
)}
</div>
) : total > 0 ? (
<div>
<p className="text-[0.65rem] font-medium uppercase tracking-widest text-zinc-500 dark:text-zinc-400">
Total sessions
</p>
<p className="mt-1 text-3xl font-semibold tabular-nums tracking-tight sm:text-4xl">
{nf.format(total)}
</p>
<p className="mt-0.5 text-xs font-medium text-zinc-500 dark:text-zinc-400">
{visibleCount} of {CHANNELS.length} channels · {periodLabel}
</p>
</div>
) : (
<div>
<p className="text-sm font-medium text-zinc-500 dark:text-zinc-400">
No channels selected
</p>
<p className="mt-1 text-xs text-zinc-400 dark:text-zinc-500">
Turn a channel back on to see the mix.
</p>
</div>
)}
</div>
</div>
{/* Period control */}
<div
role="radiogroup"
aria-label="Reporting period"
className="mt-6 inline-flex rounded-full border border-zinc-200 bg-zinc-100 p-1 dark:border-zinc-800 dark:bg-zinc-800/60"
>
{PERIODS.map((p, idx) => {
const selected = p.id === period;
return (
<button
key={p.id}
ref={(el) => {
radioRefs.current[idx] = el;
}}
type="button"
role="radio"
aria-checked={selected}
tabIndex={selected ? 0 : -1}
onClick={() => setPeriod(p.id)}
onKeyDown={(e) => onRadioKey(e, idx)}
className={`rounded-full px-4 py-1.5 text-sm font-medium outline-none transition focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-800 ${
selected
? "bg-white text-zinc-900 shadow-sm dark:bg-zinc-950 dark:text-zinc-50"
: "text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200"
}`}
>
{p.label}
</button>
);
})}
</div>
</div>
{/* Legend */}
<div>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-zinc-700 dark:text-zinc-300" id={`${uid}-legend`}>
Channels
</h3>
<button
type="button"
aria-disabled={hidden.size === 0}
onClick={() => {
if (hidden.size > 0) setHidden(new Set());
}}
className={`rounded-md px-1.5 py-0.5 text-xs font-medium outline-none transition focus-visible:ring-2 focus-visible:ring-indigo-500 ${
hidden.size === 0
? "cursor-default text-zinc-400 dark:text-zinc-600"
: "text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300"
}`}
>
Reset all
</button>
</div>
<ul aria-labelledby={`${uid}-legend`} className="space-y-1">
{CHANNELS.map((c, i) => {
const isHidden = hidden.has(c.id);
const share = shareById.get(c.id);
return (
<li key={c.id}>
<button
type="button"
aria-pressed={!isHidden}
onClick={() => toggleHidden(c.id)}
onMouseEnter={() => !isHidden && setActive(c.id)}
onMouseLeave={() => setActive(null)}
onFocus={() => !isHidden && setActive(c.id)}
onBlur={() => setActive(null)}
className={`chartpie-anim flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left outline-none transition focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-50 dark:focus-visible:ring-offset-zinc-900 ${c.ring} hover:bg-white dark:hover:bg-zinc-800/50 ${
isHidden ? "opacity-50" : ""
}`}
style={{ animation: `chartpie-rise 0.5s ease-out ${0.15 + i * 0.05}s both` }}
>
<span
aria-hidden="true"
className={`h-3 w-3 shrink-0 rounded-full ${
isHidden
? "border border-zinc-400 dark:border-zinc-500"
: c.swatch
}`}
/>
<span className="min-w-0 flex-1">
<span
className={`block truncate text-sm font-medium ${
isHidden
? "text-zinc-500 line-through dark:text-zinc-500"
: "text-zinc-900 dark:text-zinc-100"
}`}
>
{c.label}
</span>
<span className="block truncate text-xs text-zinc-500 dark:text-zinc-400">
{c.hint}
</span>
</span>
<span className="shrink-0 text-right">
<span className="block text-sm font-semibold tabular-nums text-zinc-900 dark:text-zinc-100">
{nf.format(amount(c, period))}
</span>
<span className="block text-xs tabular-nums text-zinc-500 dark:text-zinc-400">
{isHidden || share === undefined
? "hidden"
: `${(share * 100).toFixed(1)}%`}
</span>
</span>
</button>
</li>
);
})}
</ul>
<p className="mt-4 border-t border-zinc-200 pt-3 text-xs text-zinc-500 dark:border-zinc-800 dark:text-zinc-400">
Source: Northwind Analytics · Sessions, not unique visitors · MoM = month over month.
</p>
</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 Donut
OriginalSVG donut chart with centre label

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

