DonutChart
Preview
Basic
Loading…
Preview
Code
ts
import DonutChart from '@/components/data/DonutChart';src/components/data/DonutChart.tsx
AI prompt
text
Build a donut chart component in React + TypeScript + Tailwind CSS, drawn with Recharts (`PieChart`, `Pie`, `Cell`), for part-to-whole at a glance with every slice also listed beside the ring.
## Look
- Frame: a panel card (`p-5`) with a header holding the title as a 10px uppercase section title plus an ⓘ hint tooltip, and at the right a two-icon chart / table toggle (lucide `BarChart3` / `Table2`). No legend in the header — the list beside the ring is the legend.
- Body: `flex flex-col items-center gap-4 sm:flex-row` — ring, then list.
- Ring: a square `height` × `height` box (default 220px). Inner radius 64%, outer 100%, starting at 12 o'clock and running clockwise. Slices are separated by a 2px stroke in the card's colour (#ffffff / dark #1e293b), not a border.
- Centre overlay (pointer-events none): the total in `text-xl` semibold slate-900 / dark slate-50, and under it `centerLabel` in 11px slate-500.
- Slice colours, by rank after sorting: light #2a78d6, #eb6834, #1baf7a, #eda100, #e87ba4, #008300; dark #3987e5, #d95926, #199e70, #c98500, #d55181, #008300. The folded "Other" is grey #94a3b8 / dark #64748b — it is context, not an entity.
- List (`flex-1 min-w-0 space-y-1.5 text-xs`): per slice a 10px `rounded-sm` swatch, the label (truncating, slate-600 / dark slate-300), the value (medium, tabular-nums, slate-900 / dark slate-100), and the share (`w-12` right-aligned, tabular-nums, slate-500), e.g. "40.9%".
## Behaviour
- Slices sort largest first. Past SIX slices the top five stay and the rest fold into one "Other" with their summed value — never a seventh hue. A donut is for "roughly what share", not for ranking close values.
- Share = value / total to one decimal ("—" when the total is 0). Hover tooltip: an opaque floating card with a 14×3px colour stroke, "4.8K · 40.9%" in semibold tabular-nums and the slice name in slate-500.
- Table view columns: Segment, Value (right, formatted), Share (right). Total of 0 → the 240px "No data for the selected period." placeholder.
- Animation off. Default format: compact number.
## API
`title`, `hint?`, `data: { label: string; value: number }[]`, `format?: (v: number) => string`, `centerLabel = 'Total'`, `height = 220`, `className?`.
## Demo
"Sign-ups by channel" in a `max-w-xl` wrapper, seven channels so two fold into Other: Organic search 4,820, Paid social 2,950, Referral 1,730, Partners 1,120, Events 640, Podcast 310, Other 205.
## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.Source
tsx
'use client';
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
import ChartCard, { ChartTooltip } from './ChartCard';
import { compactNumber, useChartPalette } from './chartTheme';
export type DonutSlice = { label: string; value: number };
/** Past this many slices, the rest fold into "Other". */
const MAX_SLICES = 6;
/**
* Part-to-whole at a glance — a handful of slices around the total.
*
* A donut is for "roughly what share", never for comparing close values;
* reach for a bar when the reader needs to rank the parts. So it is capped:
* past six slices the smallest fold into "Other" (never a seventh generated
* hue), and every slice is also listed beside the ring with its value and
* share, which is what makes it readable at all — identity never rests on
* matching a colour.
*/
export default function DonutChart({
title,
hint,
data,
format = compactNumber,
centerLabel = 'Total',
height = 220,
className,
}: {
title: string;
hint?: string;
data: DonutSlice[];
format?: (v: number) => string;
centerLabel?: string;
height?: number;
className?: string;
}) {
const p = useChartPalette();
const sorted = [...data].sort((a, b) => b.value - a.value);
const slices =
sorted.length > MAX_SLICES
? [...sorted.slice(0, MAX_SLICES - 1), { label: 'Other', value: sorted.slice(MAX_SLICES - 1).reduce((s, d) => s + d.value, 0) }]
: sorted;
const total = slices.reduce((s, d) => s + d.value, 0);
const share = (v: number) => (total ? `${((v / total) * 100).toFixed(1)}%` : '—');
// "Other" is context, not an entity: it takes the grey, not a hue.
const colorOf = (d: DonutSlice, i: number) => (d.label === 'Other' && sorted.length > MAX_SLICES ? p.muted : p.categorical[i]);
return (
<ChartCard
title={title}
hint={hint}
className={className}
empty={total === 0}
table={{
columns: [
{ key: 'label', label: 'Segment' },
{ key: 'value', label: 'Value', align: 'right', format: (v) => format(Number(v)) },
{ key: 'share', label: 'Share', align: 'right' },
],
rows: slices.map((d) => ({ ...d, share: share(d.value) })),
}}
>
<div className="flex flex-col items-center gap-4 sm:flex-row">
<div className="relative shrink-0" style={{ width: height, height }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={slices}
dataKey="value"
nameKey="label"
innerRadius="64%"
outerRadius="100%"
startAngle={90}
endAngle={-270}
// The 2px surface gap between slices.
stroke={p.surface}
strokeWidth={2}
isAnimationActive={false}
>
{slices.map((d, i) => (
<Cell key={d.label} fill={colorOf(d, i)} />
))}
</Pie>
<Tooltip content={(props) => <ChartTooltip {...props} format={(v) => `${format(v)} · ${share(v)}`} />} />
</PieChart>
</ResponsiveContainer>
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<span className="text-xl font-semibold text-slate-900 dark:text-slate-50">{format(total)}</span>
<span className="text-[11px] text-slate-500 dark:text-slate-400">{centerLabel}</span>
</div>
</div>
<ul className="w-full min-w-0 flex-1 space-y-1.5 text-xs">
{slices.map((d, i) => (
<li key={d.label} className="flex items-center gap-2">
<span aria-hidden className="h-2.5 w-2.5 shrink-0 rounded-sm" style={{ background: colorOf(d, i) }} />
<span className="min-w-0 flex-1 truncate text-slate-600 dark:text-slate-300">{d.label}</span>
<span className="tabular-nums font-medium text-slate-900 dark:text-slate-100">{format(d.value)}</span>
<span className="w-12 text-right tabular-nums text-slate-500 dark:text-slate-400">{share(d.value)}</span>
</li>
))}
</ul>
</div>
</ChartCard>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
title* | string | — | |
data* | DonutSlice[] | — | |
hint | string | — | |
format | (v: number) => string | compactNumber | |
centerLabel | string | 'Total' | |
height | number | 220 | |
className | string | — |