"use client";
import * as React from "react";
export interface DonutDatum {
/** Stable identity: colour and animation follow the id, not the position. */
id: string;
label: string;
value: number;
/** Segment colour; falls back to the categorical palette (accent first). */
color?: string;
}
export interface DonutChartProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
data: DonutDatum[];
/** Accessible name; also the caption of the screen-reader table. */
label: string;
/** Value formatter used in the centre, legend, labels and table. */
format?: (value: number) => string;
/** Ring thickness in px (at the 240px reference size). */
thickness?: number;
/** Gap between segments, in degrees. */
padAngle?: number;
/** Rounded segment ends. */
rounded?: boolean;
/** Arc tween duration for entry and re-flow, in ms. */
duration?: number;
/** How far the active segment slides out, in px. */
expand?: number;
/** Legend placement; the legend buttons toggle segments. */
legend?: "right" | "bottom" | "none";
/** Caption under the total when no segment is active. */
centerLabel?: string;
/** Maximum chart diameter in px. */
size?: number;
/** Colour of the first segment without its own colour. */
accent?: string;
}
/* --------------------------- reduced-motion hook --------------------------- */
const REDUCED_QUERY = "(prefers-reduced-motion: reduce)";
function subscribeReducedMotion(onChange: () => void) {
const mq = window.matchMedia(REDUCED_QUERY);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia(REDUCED_QUERY).matches,
() => false,
);
}
/* --------------------------------- palette -------------------------------- */
// Categorical slots, stepped separately for light and dark surfaces (CVD-checked as a set).
// Colour follows the datum's position in `data`, so hiding a segment never repaints the others.
const PALETTE_VARS =
"[--dc-2:#2a78d6] [--dc-3:#1baf7a] [--dc-4:#eda100] [--dc-5:#e87ba4] [--dc-6:#4a3aa7] [--dc-rest:#a1a1aa] " +
"dark:[--dc-2:#3987e5] dark:[--dc-3:#199e70] dark:[--dc-4:#c98500] dark:[--dc-5:#d55181] dark:[--dc-6:#9085e9] dark:[--dc-rest:#71717a]";
function colorFor(d: DonutDatum, index: number) {
if (d.color) return d.color;
if (index === 0) return "var(--dc-accent)";
// Past six slots, segments fold into a neutral instead of cycling hues.
return index < 6 ? `var(--dc-${index + 1})` : "var(--dc-rest)";
}
/* -------------------------------- geometry -------------------------------- */
const VB = 240;
const C = VB / 2;
const TAU_DEG = 360;
interface Span {
start: number;
end: number;
}
function point(angle: number, r: number) {
const rad = ((angle - 90) * Math.PI) / 180;
return `${(C + r * Math.cos(rad)).toFixed(3)} ${(C + r * Math.sin(rad)).toFixed(3)}`;
}
/** Stroke path and width for an arc from `start` to `end` (degrees clockwise from 12 o'clock). */
function arc(span: Span, r: number, thickness: number, rounded: boolean) {
const sweep = span.end - span.start;
if (sweep <= 0.01) return { d: "", width: 0 };
// Round caps stick out by half the thickness; pull the ends in so the gap stays true.
const cap = rounded ? ((thickness / 2 / r) * 180) / Math.PI : 0;
if (sweep <= cap * 2) {
// Too small for a full cap: draw a dot that grows with the segment.
const p = point((span.start + span.end) / 2, r);
return { d: `M ${p} L ${p}`, width: thickness * (sweep / (cap * 2)) };
}
const s = span.start + cap;
const e = span.end - cap;
if (e - s >= 359.99) {
return { d: `M ${point(0, r)} A ${r} ${r} 0 1 1 ${point(180, r)} A ${r} ${r} 0 1 1 ${point(359.99, r)}`, width: thickness };
}
return { d: `M ${point(s, r)} A ${r} ${r} 0 ${e - s > 180 ? 1 : 0} 1 ${point(e, r)}`, width: thickness };
}
const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
/* -------------------------------- component -------------------------------- */
/**
* An interactive donut chart. Segments sweep in, re-flow smoothly when the legend hides one,
* and the hovered or focused segment slides out while its value takes the centre. One tab stop
* with arrow keys between segments, plus a screen-reader table of the full data.
*/
export function DonutChart({
data,
label,
format,
thickness = 28,
padAngle = 1.5,
rounded = true,
duration = 900,
expand = 6,
legend = "right",
centerLabel = "Total",
size = 260,
accent = "#FF7448",
className = "",
style,
...props
}: DonutChartProps) {
const reduceMotion = usePrefersReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
const spansRef = React.useRef<Map<string, Span>>(new Map());
const [hidden, setHidden] = React.useState<ReadonlySet<string>>(() => new Set());
const [active, setActive] = React.useState<string | null>(null);
const [focusId, setFocusId] = React.useState<string | null>(null);
const hintId = React.useId();
const fmt = React.useMemo(() => {
if (format) return format;
const nf = new Intl.NumberFormat("en-US", { maximumFractionDigits: 1 });
return (v: number) => nf.format(v);
}, [format]);
const pct = (v: number, of: number) => (of > 0 ? `${((v / of) * 100).toFixed(1)}%` : "0%");
const items = data.map((d, i) => ({ ...d, value: Math.max(0, d.value), color: colorFor(d, i) }));
const shown = items.filter((d) => !hidden.has(d.id) && d.value > 0);
const total = shown.reduce((sum, d) => sum + d.value, 0);
const grandTotal = items.reduce((sum, d) => sum + d.value, 0);
// Target spans: visible segments share 360° minus the gaps; hidden ones collapse in place.
const gaps = shown.length > 1 ? shown.length * padAngle : 0;
const available = Math.max(0, TAU_DEG - gaps);
const half = shown.length > 1 ? padAngle / 2 : 0;
const targets = new Map<string, Span>();
let cursor = 0;
for (const d of items) {
if (shown.includes(d)) {
const sweep = total > 0 ? (d.value / total) * available : 0;
const start = cursor + half;
targets.set(d.id, { start, end: start + sweep });
cursor = start + sweep + half;
} else {
targets.set(d.id, { start: cursor, end: cursor });
}
}
const targetKey = JSON.stringify([...targets]);
const r = C - thickness / 2 - Math.max(0, expand) - 2;
const ms = reduceMotion ? 0 : duration;
// Arc tween: paths are written imperatively from the spans on screen to the new targets.
React.useEffect(() => {
const root = rootRef.current;
if (!root) return;
const nextSpans = new Map<string, Span>(JSON.parse(targetKey) as [string, Span][]);
const from = new Map(spansRef.current);
const paths = Array.from(root.querySelectorAll<SVGPathElement>("[data-dc-id]"));
const draw = (t: number) => {
const current = new Map<string, Span>();
for (const [id, to] of nextSpans) {
const f = from.get(id) ?? { start: 0, end: 0 };
current.set(id, { start: f.start + (to.start - f.start) * t, end: f.end + (to.end - f.end) * t });
}
spansRef.current = current;
for (const path of paths) {
const span = current.get(path.dataset.dcId ?? "");
if (!span) continue;
const { d, width } = arc(span, r, thickness, rounded);
const ring = path.dataset.dcRing !== undefined;
path.setAttribute("d", d);
path.setAttribute("stroke-width", String(ring && width > 0 ? width + 5 : width));
}
};
if (ms <= 0) {
draw(1);
return;
}
let frame = 0;
let start = -1; // set from the first frame's timestamp
const tick = (now: number) => {
if (start < 0) start = now;
const t = Math.min(1, (now - start) / ms);
draw(easeOutCubic(t));
if (t < 1) frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [targetKey, r, thickness, rounded, ms]);
const tabId = shown.some((d) => d.id === focusId) ? focusId : (shown[0]?.id ?? null);
const activeItem = shown.find((d) => d.id === active) ?? null;
const focusSegment = (id: string) => {
rootRef.current?.querySelector<SVGPathElement>(`[data-dc-seg="${CSS.escape(id)}"]`)?.focus();
};
const onKeyDown = (event: React.KeyboardEvent<SVGPathElement>, id: string) => {
const i = shown.findIndex((d) => d.id === id);
if (i < 0 || !shown.length) return;
let next = -1;
if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (i + 1) % shown.length;
else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (i - 1 + shown.length) % shown.length;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = shown.length - 1;
else if (event.key === "Escape") setActive(null);
if (next < 0) return;
event.preventDefault();
focusSegment(shown[next].id);
};
const toggle = (id: string) => {
setHidden((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
if (active === id) setActive(null);
};
const legendLayout = legend === "right" ? "@lg:flex-row @lg:items-center" : "";
return (
<div
ref={rootRef}
role="group"
aria-label={label}
className={`@container w-full text-zinc-950 dark:text-zinc-50 ${PALETTE_VARS} ${className}`}
style={{ ["--dc-accent" as string]: accent, ...style }}
{...props}
>
<div className={`flex flex-col items-center gap-6 ${legendLayout}`}>
<div className="relative aspect-square w-full shrink-0 @container" style={{ maxWidth: size }}>
<svg viewBox={`0 0 ${VB} ${VB}`} className="absolute inset-0 size-full overflow-visible" onPointerLeave={(e) => e.pointerType === "mouse" && setActive(null)}>
{/* Recessive track behind the segments. */}
<circle cx={C} cy={C} r={r} fill="none" strokeWidth={thickness} className="stroke-zinc-900/[0.05] dark:stroke-white/[0.06]" />
<g role="list" aria-label={`${label} segments`} aria-describedby={hintId}>
{items.map((d) => {
const isShown = shown.includes(d);
const span = targets.get(d.id);
const mid = span ? (span.start + span.end) / 2 : 0;
const rad = ((mid - 90) * Math.PI) / 180;
const out = active === d.id ? expand : 0;
return (
<g
key={d.id}
className="group transition-transform duration-200 ease-out motion-reduce:transition-none"
style={{ transform: `translate(${(Math.cos(rad) * out).toFixed(2)}px, ${(Math.sin(rad) * out).toFixed(2)}px)` }}
>
{/* Focus ring: a wider arc behind the segment, shown for keyboard focus only. */}
<path
data-dc-id={d.id}
data-dc-ring=""
aria-hidden="true"
fill="none"
strokeLinecap={rounded ? "round" : "butt"}
className="pointer-events-none stroke-zinc-950 opacity-0 group-has-[:focus-visible]:opacity-100 dark:stroke-white"
/>
<path
data-dc-id={d.id}
data-dc-seg={d.id}
role={isShown ? "listitem" : undefined}
aria-hidden={isShown ? undefined : true}
aria-label={isShown ? `${d.label}: ${fmt(d.value)}, ${pct(d.value, total)}` : undefined}
tabIndex={isShown ? (tabId === d.id ? 0 : -1) : undefined}
fill="none"
stroke={d.color}
strokeLinecap={rounded ? "round" : "butt"}
className={`cursor-pointer outline-none transition-opacity duration-200 ${
active && active !== d.id ? "opacity-45" : "opacity-100"
}`}
onPointerEnter={(e) => e.pointerType === "mouse" && isShown && setActive(d.id)}
onClick={() => isShown && setActive((a) => (a === d.id ? null : d.id))}
onFocus={() => {
setFocusId(d.id);
setActive(d.id);
}}
onBlur={() => setActive(null)}
onKeyDown={(e) => onKeyDown(e, d.id)}
/>
</g>
);
})}
</g>
</svg>
{/* Centre read-out (decorative: the focused segment's label carries the same text). */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center">
<span className="max-w-[46cqw] truncate font-mono text-[max(9px,4.2cqw)] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
{activeItem ? activeItem.label : centerLabel}
</span>
<span className="text-[max(18px,13cqw)] leading-none font-semibold tracking-[-0.04em] tabular-nums">
{activeItem ? fmt(activeItem.value) : fmt(total)}
</span>
<span className="mt-1 h-[1.4em] font-mono text-[max(9px,4.2cqw)] tabular-nums text-zinc-500 dark:text-zinc-400">
{activeItem ? pct(activeItem.value, total) : shown.length ? `${shown.length} of ${items.length}` : "No data"}
</span>
</div>
</div>
{legend !== "none" ? (
<ul className="flex w-full min-w-0 flex-col gap-0.5 @lg:max-w-64" aria-label={`${label} legend`}>
{items.map((d) => {
const off = hidden.has(d.id);
return (
<li key={d.id}>
<button
type="button"
aria-pressed={!off}
onClick={() => toggle(d.id)}
onPointerEnter={(e) => e.pointerType === "mouse" && !off && setActive(d.id)}
onPointerLeave={(e) => e.pointerType === "mouse" && setActive(null)}
className={`grid w-full grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 rounded-md px-2 py-1.5 text-left text-sm transition-colors hover:bg-zinc-900/[0.04] focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--dc-accent)] dark:hover:bg-white/[0.05] ${
off ? "text-zinc-400 dark:text-zinc-500" : ""
} ${active === d.id ? "bg-zinc-900/[0.04] dark:bg-white/[0.05]" : ""}`}
>
<span
aria-hidden="true"
className={`size-2.5 rounded-[3px] border transition-colors ${off ? "border-current bg-transparent" : "border-transparent"}`}
style={off ? undefined : { backgroundColor: d.color }}
/>
<span className={`truncate ${off ? "line-through decoration-1" : ""}`}>{d.label}</span>
<span className="font-mono text-[11px] tabular-nums text-zinc-500 dark:text-zinc-400">
{off ? "hidden" : `${fmt(d.value)} · ${pct(d.value, total)}`}
</span>
</button>
</li>
);
})}
</ul>
) : null}
</div>
<p id={hintId} className="sr-only">
Use the arrow keys to move between segments.
</p>
<table className="sr-only">
<caption>{label}</caption>
<thead>
<tr>
<th scope="col">Segment</th>
<th scope="col">Value</th>
<th scope="col">Share</th>
</tr>
</thead>
<tbody>
{items.map((d) => (
<tr key={d.id}>
<th scope="row">{d.label}</th>
<td>{fmt(d.value)}</td>
<td>{pct(d.value, grandTotal)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}