An SVG donut whose segments sweep in sequentially while the center value counts up, with hover-linked legend and segment expansion.
npx shadcn@latest add @paragon/donut-chartAlso installs: number-ticker
"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { NumberTicker } from "@/registry/paragon/ui/number-ticker";
import { cn } from "@/lib/utils";
const SERIES_COLORS = [
"oklch(0.585 0.17 260)",
"oklch(0.68 0.13 165)",
"oklch(0.76 0.13 85)",
"oklch(0.62 0.18 305)",
"oklch(0.66 0.16 25)",
];
export interface DonutChartDatum {
label: string;
value: number;
color?: string;
}
export interface DonutChartProps extends React.ComponentProps<"div"> {
data: DonutChartDatum[];
size?: number;
/** Ring thickness in px. */
thickness?: number;
/** Gap between segments, in degrees. */
gap?: number;
/** Caption under the center value. */
centerLabel?: string;
/** Center value. Defaults to the sum of all segments. */
centerValue?: number;
/** Hide the center total readout. */
showCenter?: boolean;
/** Intl.NumberFormat options for the center value and legend. */
formatOptions?: Intl.NumberFormatOptions;
showLegend?: boolean;
/** Accessible description of the chart. */
label?: string;
/** Renders the final state immediately, no sweep-in. */
static?: boolean;
}
/** Polar → cartesian. Angle in degrees, 0° at 12 o'clock, clockwise. */
function polar(cx: number, cy: number, r: number, deg: number) {
const rad = ((deg - 90) * Math.PI) / 180;
return [cx + r * Math.cos(rad), cy + r * Math.sin(rad)] as const;
}
/** An arc path from `startDeg` to `endDeg` at radius `r`, drawn clockwise. */
function arcPath(
cx: number,
cy: number,
r: number,
startDeg: number,
endDeg: number,
) {
// A single segment spanning the whole ring degenerates (start ≈ end) —
// draw it as two half-circle arcs so it renders as a closed ring.
if (endDeg - startDeg >= 359.9) {
const [x1, y1] = polar(cx, cy, r, startDeg);
const [xm, ym] = polar(cx, cy, r, startDeg + 180);
return `M ${x1.toFixed(3)} ${y1.toFixed(3)} A ${r} ${r} 0 1 1 ${xm.toFixed(3)} ${ym.toFixed(3)} A ${r} ${r} 0 1 1 ${x1.toFixed(3)} ${y1.toFixed(3)}`;
}
const [x1, y1] = polar(cx, cy, r, startDeg);
const [x2, y2] = polar(cx, cy, r, endDeg);
const large = endDeg - startDeg > 180 ? 1 : 0;
return `M ${x1.toFixed(3)} ${y1.toFixed(3)} A ${r} ${r} 0 ${large} 1 ${x2.toFixed(3)} ${y2.toFixed(3)}`;
}
/**
* A hand-built SVG donut with parametrically computed arc segments. Every
* segment start/end angle is derived from the running cumulative fraction —
* no gaps or overlaps. Segments sweep in from zero once on first view via a
* single masked reveal while the center value counts up.
*
* Interactive: hover or focus a segment (or its legend row) to lift it 2px
* outward and dim the rest, with a cursor-anchored tooltip showing the value
* and share; click a legend row to toggle its segment out of the total.
* Reduced motion renders the final state immediately.
*/
export function DonutChart({
data,
size = 200,
thickness = 22,
gap = 2,
centerLabel,
centerValue,
showCenter = true,
formatOptions,
showLegend = true,
label,
static: isStatic = false,
className,
...props
}: DonutChartProps) {
const containerRef = React.useRef<HTMLDivElement>(null);
const reducedMotion = useReducedMotion();
const inView = useInView(containerRef, {
once: true,
margin: "0px 0px -48px 0px",
});
const [active, setActive] = React.useState<number | null>(null);
const [hidden, setHidden] = React.useState<Set<number>>(new Set());
const [cursor, setCursor] = React.useState<{ x: number; y: number } | null>(
null,
);
const maskId = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
const animate = !isStatic && !reducedMotion;
const drawn = !animate || inView;
const c = size / 2;
const r = (size - thickness) / 2 - 3;
const format = React.useMemo(
() => new Intl.NumberFormat("en-US", formatOptions),
// eslint-disable-next-line react-hooks/exhaustive-deps
[JSON.stringify(formatOptions)],
);
const percentFormat = React.useMemo(
() =>
new Intl.NumberFormat("en-US", {
style: "percent",
maximumFractionDigits: 1,
}),
[],
);
const colorFor = (i: number) =>
data[i]?.color ?? SERIES_COLORS[i % SERIES_COLORS.length];
const visibleTotal = Math.max(
data.reduce((sum, d, i) => (hidden.has(i) ? sum : sum + d.value), 0),
1e-9,
);
const isEmpty = data.length === 0;
const allHidden =
!isEmpty && data.every((_, i) => hidden.has(i));
// Parametric segment angles from the running cumulative fraction.
const gapDeg = data.length > 1 ? gap : 0;
let cursorDeg = 0;
const segments = data.map((d, i) => {
const isHidden = hidden.has(i);
const sweep = isHidden ? 0 : (d.value / visibleTotal) * 360;
const startDeg = cursorDeg + gapDeg / 2;
const endDeg = cursorDeg + sweep - gapDeg / 2;
cursorDeg += sweep;
const midDeg = (startDeg + endDeg) / 2;
const [ox, oy] = polar(0, 0, 1, midDeg); // outward unit vector
return {
...d,
index: i,
startDeg,
endDeg,
midDeg,
sweep,
isHidden,
offset: [ox, oy] as const,
};
});
const activeSeg = active !== null ? segments[active] : null;
const displayTotal = centerValue ?? visibleTotal;
const onMove = (e: React.PointerEvent) => {
const rect = containerRef.current
?.querySelector("[data-donut-svg-wrap]")
?.getBoundingClientRect();
if (!rect) return;
setCursor({ x: e.clientX - rect.left, y: e.clientY - rect.top });
};
return (
<div
ref={containerRef}
data-slot="donut-chart"
className={cn("flex flex-wrap items-center gap-6", className)}
{...props}
>
<div
data-donut-svg-wrap
className="relative shrink-0"
style={{ width: size, height: size }}
onPointerMove={onMove}
>
<svg
role="img"
aria-label={
label ??
`Donut chart: ${data
.map((d) => `${d.label} ${format.format(d.value)}`)
.join(", ")}`
}
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className="block"
onPointerLeave={() => {
setActive(null);
setCursor(null);
}}
>
<defs>
<mask id={maskId}>
{/* One sweeping mask reveals every segment in sequence. */}
<circle
cx={c}
cy={c}
r={r}
fill="none"
stroke="#fff"
strokeWidth={thickness + 10}
pathLength={1}
strokeDasharray={1}
transform={`rotate(-90 ${c} ${c})`}
style={{
strokeDashoffset: drawn ? 0 : 1,
transition: animate
? "stroke-dashoffset 650ms var(--ease-out)"
: undefined,
}}
/>
</mask>
</defs>
{/* Faint track so an empty / all-hidden chart still reads as a ring. */}
<circle
cx={c}
cy={c}
r={r}
fill="none"
stroke="currentColor"
strokeOpacity={0.08}
strokeWidth={thickness}
/>
<g mask={`url(#${maskId})`}>
{segments.map((segment) => {
if (segment.isHidden || segment.sweep <= 0) return null;
const isActive = active === segment.index;
const dimmed = active !== null && !isActive;
const dx = segment.offset[0] * 2;
const dy = segment.offset[1] * 2;
const lift = isActive && !reducedMotion;
return (
<path
key={segment.index}
role="button"
tabIndex={0}
aria-label={`${segment.label}: ${format.format(
segment.value,
)}, ${percentFormat.format(segment.value / visibleTotal)}`}
d={arcPath(c, c, r, segment.startDeg, segment.endDeg)}
fill="none"
stroke={colorFor(segment.index)}
strokeWidth={thickness}
strokeLinecap={segment.sweep >= 359.5 ? "butt" : "round"}
vectorEffect="non-scaling-stroke"
onPointerEnter={() => setActive(segment.index)}
onFocus={() => setActive(segment.index)}
onBlur={() => setActive(null)}
className="cursor-pointer outline-none focus-visible:stroke-foreground"
style={{
opacity: dimmed ? 0.4 : 1,
transform: lift
? `translate(${dx.toFixed(2)}px, ${dy.toFixed(2)}px)`
: "translate(0px, 0px)",
transition:
"transform 150ms var(--ease-out), opacity 150ms var(--ease-out), stroke 150ms var(--ease-out)",
}}
/>
);
})}
</g>
</svg>
{showCenter && (
<div
aria-hidden
className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center"
style={{ padding: thickness + 6 }}
>
{activeSeg ? (
<>
<span
className="max-w-full truncate text-center text-[11px] font-medium text-muted-foreground"
style={{ maxWidth: size - thickness * 2 - 12 }}
>
{activeSeg.label}
</span>
<span className="text-xl font-semibold tabular-nums">
{format.format(activeSeg.value)}
</span>
<span className="text-xs text-muted-foreground tabular-nums">
{percentFormat.format(activeSeg.value / visibleTotal)}
</span>
</>
) : (
<>
<NumberTicker
value={displayTotal}
duration={0.6}
formatOptions={formatOptions}
static={isStatic}
className="text-xl font-semibold"
/>
{centerLabel && (
<span
className="mt-0.5 max-w-full truncate text-center text-xs text-muted-foreground"
style={{ maxWidth: size - thickness * 2 - 12 }}
>
{centerLabel}
</span>
)}
</>
)}
</div>
)}
{/* Cursor-anchored tooltip. */}
{activeSeg && cursor && !reducedMotion && (
<div
role="status"
className="pointer-events-none absolute z-10 flex items-center gap-2 rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground shadow-overlay"
style={{
left: cursor.x,
top: cursor.y,
transform: "translate(-50%, calc(-100% - 8px))",
whiteSpace: "nowrap",
}}
>
<span
className="size-2 shrink-0 rounded-full"
style={{ background: colorFor(activeSeg.index) }}
/>
<span>{activeSeg.label}</span>
<span className="tabular-nums opacity-80">
{format.format(activeSeg.value)}
</span>
</div>
)}
{(isEmpty || allHidden) && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="text-xs text-muted-foreground">No data</span>
</div>
)}
</div>
{showLegend && !isEmpty && (
<div
className="flex min-w-32 flex-col gap-0.5"
onPointerLeave={() => setActive(null)}
>
{data.map((d, i) => {
const isHidden = hidden.has(i);
return (
<button
key={i}
type="button"
aria-pressed={!isHidden}
onPointerEnter={() => !isHidden && setActive(i)}
onFocus={() => !isHidden && setActive(i)}
onBlur={() => setActive(null)}
onClick={() =>
setHidden((prev) => {
const next = new Set(prev);
if (next.has(i)) next.delete(i);
else next.add(i);
// Keep at least one series visible.
if (next.size === data.length) return prev;
return next;
})
}
className={cn(
"pressable flex items-center gap-2 rounded-md px-1.5 py-1 text-left text-sm outline-none transition-opacity duration-150 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card",
active !== null && active !== i && !isHidden && "opacity-50",
)}
>
<span
className="size-2 shrink-0 rounded-full transition-opacity duration-150"
style={{
background: colorFor(i),
opacity: isHidden ? 0.35 : 1,
}}
/>
<span
className={cn(
"min-w-0 truncate text-muted-foreground transition-colors",
isHidden && "line-through opacity-50",
)}
>
{d.label}
</span>
<span
className={cn(
"ml-auto shrink-0 pl-4 font-medium tabular-nums",
isHidden && "opacity-50",
)}
>
{format.format(d.value)}
</span>
</button>
);
})}
</div>
)}
</div>
);
}