Donut chart
Part to whole as a ring or a half gauge, the total in the middle, a legend with values and shares.
One of 7 components in Charts
Variants
Details
What Donut chart brings along, read from the registry and the source.
- Category
- Charts
- Area
- Elements
- Variants
- 3
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/donut-chart.tsx
- Brings along
- utilskitchart
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The DonutChartProps interface from components/ui/donut-chart.tsx, with the comments from the code. Also takes every prop of ChartA11y.
datarequiredDonutSlice[]Up to four slices read best. Fold the rest into "Other".
formatoptionalIntl.NumberFormatOptionsValue format, e.g. { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }
variantoptional'donut' | 'half'donut: full ring. half: a gauge from left to right.
maxoptionalnumberThe whole ring stands for this value. The rest stays as a quiet track (gauges, budgets). Default: the sum.
centerLabeloptionalstringSmall text in the middle, under the value. Default: "Total".
centerValueoptionalstringBig text in the middle. Default: the formatted sum.
sizeoptionalnumberDiameter in px. It shrinks with the container.
thicknessoptionalnumberRing width in px
legendoptional'side' | 'bottom' | falseLegend next to the ring (side), below it (bottom) or none
percentoptionalbooleanShare in percent next to each legend value
Accessibility
Read from the source of the component.
- Needs no client state. The server writes it in full, so it reads without JavaScript too.
Motion
- With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 147 lines
'use client';
import { cn, round, useTexts } from '@/lib/kit';
import { CHART_TEXTS, ChartLegend, CountUp, ChartRoot, ChartSurface, ChartTable, arcPath, numberFormatter, seriesColor, useActive, useEntrance, useIntlLocale, type ChartA11y } from '@/components/ui/chart';
export interface DonutSlice {
label: string;
value: number;
/** Any CSS color. Default: the slot color in order (accent, grey, two accent tones). */
color?: string;
}
export interface DonutChartProps extends ChartA11y {
/** Up to four slices read best. Fold the rest into "Other". */
data: DonutSlice[];
/** Value format, e.g. { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 } */
format?: Intl.NumberFormatOptions;
/** donut: full ring. half: a gauge from left to right. */
variant?: 'donut' | 'half';
/** The whole ring stands for this value. The rest stays as a quiet track (gauges, budgets). Default: the sum. */
max?: number;
/** Small text in the middle, under the value. Default: "Total". */
centerLabel?: string;
/** Big text in the middle. Default: the formatted sum. */
centerValue?: string;
/** Diameter in px. It shrinks with the container. */
size?: number;
/** Ring width in px */
thickness?: number;
/** Legend next to the ring (side), below it (bottom) or none */
legend?: 'side' | 'bottom' | false;
/** Share in percent next to each legend value */
percent?: boolean;
className?: string;
}
/** Time the whole ring takes to run on, in ms. The slices run one after another, each as long as its share, so the ring reads as one stroke. */
const RING_MS = 900;
/**
* Part to whole as a ring or a half gauge, with the total in the middle.
* <DonutChart label="Revenue by channel" data={[{ label: 'Online', value: 254310 }, …]} format={{ style: 'currency', currency: 'EUR' }} />
*/
export function DonutChart({ data, format, variant = 'donut', max, centerLabel, centerValue, size = 200, thickness, legend = 'side', percent = true, label, summary, labels, animate = true, className }: DonutChartProps) {
const intl = useIntlLocale();
const t = useTexts(CHART_TEXTS, labels);
const fmt = numberFormatter(intl, format);
const pct = numberFormatter(intl, { style: 'percent', maximumFractionDigits: 0 });
const [rootRef, anim] = useEntrance<HTMLDivElement>(animate);
const { active, setActive, onKeyDown, onFocus, onBlur } = useActive(data.length);
const half = variant === 'half';
const sum = data.reduce((a, d) => a + Math.max(0, d.value), 0);
const whole = Math.max(max ?? sum, sum) || 1;
const sweep = half ? 180 : 360;
const start = half ? -90 : 0;
const w = thickness ?? round(size * (half ? 0.12 : 0.11), 0);
const r = round(size / 2 - w / 2 - 3);
const cx = round(size / 2);
const cy = half ? round(size / 2) : cx;
const height = half ? round(size / 2 + w / 2 + 3) : size;
// The 2px surface gap at the middle of the ring, as an angle
const gapDeg = round((2 / r) * (180 / Math.PI), 3);
const showTrack = half || sum < whole;
let acc = 0;
const arcs = data.map((d, i) => {
const a = start + (acc / whole) * sweep;
acc += Math.max(0, d.value);
const b = start + (acc / whole) * sweep;
const gap = data.length > 1 || showTrack ? gapDeg : 0;
return { a: round(a + gap / 2, 3), b: round(b - gap / 2, 3), from: (a - start) / sweep, part: Math.max(0, d.value) / whole, d, i, color: seriesColor(i, d.color) };
});
// Whole percents that add up to 100 (largest remainder), so the legend never shows 101 %
const exact = data.map((d) => (Math.max(0, d.value) / whole) * 100);
const whole100 = exact.map(Math.floor);
const missing = Math.round(exact.reduce((x, y) => x + y, 0)) - whole100.reduce((x, y) => x + y, 0);
exact
.map((v, i) => [v - Math.floor(v), i] as const)
.sort((p, q) => q[0] - p[0])
.slice(0, Math.max(0, missing))
.forEach(([, i]) => (whole100[i] += 1));
const shareAt = (i: number) => pct(whole100[i] / 100);
const act = active !== null ? data[active] : null;
// The total counts up with the ring, a hovered slice shows its own value at once
const mid: React.ReactNode = act ? fmt(act.value) : centerValue ?? <CountUp value={sum} format={fmt} anim={anim} duration={900} />;
const sub = act ? act.label : centerLabel ?? t.total;
const live = act ? `${act.label}, ${fmt(act.value)}, ${shareAt(active!)}` : '';
const auto = data.map((d, i) => `${d.label} ${shareAt(i)}`).join(', ');
return (
<ChartRoot anim={anim} rootRef={rootRef} className={className}>
<div className={cn('flex flex-col items-center gap-6', legend === 'side' && 'sm:flex-row sm:gap-10')}>
<ChartSurface
label={label}
summary={summary ?? auto}
labels={labels}
live={live}
table={<ChartTable caption={label} head={['', label, '%']} rows={data.map((d, i) => [d.label, fmt(d.value), shareAt(i)])} />}
onKeyDown={onKeyDown}
onFocus={onFocus}
onBlur={onBlur}
onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}
className={cn('w-full shrink-0', half ? 'rounded-t-full' : 'rounded-full')}
style={{ maxWidth: size }}
>
<div className="relative mx-auto" style={{ maxWidth: size }}>
<svg viewBox={`0 0 ${size} ${height}`} width="100%" aria-hidden className="block overflow-visible">
{showTrack && <path d={arcPath(cx, cy, r, start, start + sweep)} fill="none" stroke="var(--kitch-empty)" strokeWidth={w} />}
{arcs.map(({ a, b, from, part, d, i, color }) =>
b > a ? (
<path
key={d.label}
d={arcPath(cx, cy, r, a, b)}
fill="none"
stroke={color}
strokeWidth={active === i ? w + 6 : w}
pathLength={1}
opacity={active !== null && active !== i ? 0.25 : 1}
onPointerEnter={() => setActive(i)}
onPointerDown={() => setActive(i)}
className={cn('transition-[stroke-width,opacity] duration-200 motion-reduce:transition-none', anim && 'kitch-draw')}
style={anim ? ({ '--kitch-d': `${round(from * RING_MS, 0)}ms`, '--kitch-t': `${round(Math.max(160, part * RING_MS), 0)}ms`, '--kitch-e': i === arcs.length - 1 ? 'var(--ease-kit)' : 'linear' } as React.CSSProperties) : undefined}
/>
) : null,
)}
</svg>
<div className={cn('pointer-events-none absolute inset-x-0 flex flex-col items-center text-center', half ? 'bottom-0' : 'inset-y-0 justify-center')}>
<span className={cn('font-semibold leading-tight tracking-tight text-foreground', size >= 180 ? 'text-[22px] sm:text-[24px]' : 'text-[18px]')}>{mid}</span>
<span className="mt-0.5 max-w-[70%] truncate text-[12px] text-muted-foreground">{sub}</span>
</div>
</div>
</ChartSurface>
{legend && (
<ChartLegend
layout="list"
className={cn('w-full', legend === 'side' ? 'sm:max-w-[18rem]' : 'max-w-[22rem]')}
active={active}
onActive={setActive}
items={data.map((d, i) => ({ label: d.label, color: seriesColor(i, d.color), shape: 'rect', value: fmt(d.value), note: percent ? shareAt(i) : undefined }))}
/>
)}
</div>
</ChartRoot>
);
}Frequent questions
How do I install Donut chart?
With aiw add @ai-website/donut-chart. The command puts components/ui/donut-chart.tsx into your project, the code is yours from then on. It brings along chart. It needs no further npm packages.
Which variants does Donut chart have?
3 variants, Donut with legend, Half gauge and Compact pair.
Can an AI assistant build with Donut chart?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Donut chart with aiw_components and install it with aiw_add @ai-website/donut-chart. Name the variant in your prompt, for example variant="half", and the assistant writes your content straight into the props.