v1.0

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

PropTypeDefaultDescription
title*string—
data*DonutSlice[]—
hintstring—
format(v: number) => stringcompactNumber
centerLabelstring'Total'
heightnumber220
classNamestring—