Pie Chart
Token-driven pie, donut, ring and half-donut charts built on Recharts v3 — clockwise from twelve, with a centre label slot, geometry-aware slice rounding, percent labels, a controlled legend and a named accessible figure.
Playground
Installation
pnpm add @tessinaui/ui rechartsUsage
import { PieChart } from "@tessinaui/ui";
import type { ChartConfig } from "@tessinaui/ui";
// config is keyed by each slice's nameKey value
const config: ChartConfig = {
chrome: { label: "Chrome", color: "var(--chart-1)" },
safari: { label: "Safari", color: "var(--chart-2)" },
firefox: { label: "Firefox", color: "var(--chart-3)" },
};
const data = [
{ browser: "chrome", visitors: 275 },
{ browser: "safari", visitors: 200 },
{ browser: "firefox", visitors: 187 },
];
<PieChart
data={data}
config={config}
dataKey="visitors"
nameKey="browser"
donut
label="Chrome leads browser share"
description="Of 662 visitors, 275 use Chrome, 200 Safari and 187 Firefox."
/>When to use one
A pie answers one question: how does this whole divide into parts? It is weakest at the thing charts are usually for — comparing quantities — because people read angles far less accurately than lengths.
- Few slices. GitHub Primer says "no more than five slices"; Adobe Spectrum
says "fewer than 6 segments". Past that, bucket the tail into an
Otherslice (see the example below) or switch to a bar chart. - Parts of one total. If the values do not sum to a meaningful whole, a
pie is the wrong shape — use
BarChart. - Comparing categories precisely →
BarChart. Change over time →LineChartorAreaChart. One value against a target →MeterorProgress. Concentric progress rings →RadialChart. - A donut buys you the middle, which is why most real donuts put the total
there. Reach for
donutwhenever you have a number worth stating.
Negative values cannot be drawn as a share of a whole; the component warns in development and the slice would sweep backwards over its neighbours.
Examples
Default
A plain pie. Slices sweep clockwise from twelve o'clock and carry a hairline separator in the chart surface colour, so two similar neighbours stay distinguishable.
Donut with a total
Ring
A thin ring with gaps and rounded caps — the summary-glyph shape.
Gaps and rounded caps
sliceGap opens a real angular gap; rounded caps the ends. The corner radius
is resolved against each slice's own geometry, so a narrow slice keeps its
rounding instead of silently squaring off.
Half donut
startAngle / endAngle are plain degrees, so a gauge is a range rather than
a separate component.
Corner rounding
Percent labels
Labels inside the slice
Value formatting
Interactive legend
Bucketing the tail into "Other"
No charting library surveyed ships small-slice bucketing, so it is a data transform rather than a prop.
Minimal
Accessible figure
States
The skeleton mirrors the chart it stands in for — a ring for a donut, a disc for a pie — and keeps its own loading announcement. An error carries the error intent, so it never reads as neutral chrome the way an empty state does.
Accessibility
labelis the accessible name, and it is not optional in practice. Recharts makes the chart svg a keyboard-focusablerole="application"surface; withoutlabelthe root carries no role at all, soaria-labelon it is ignored and a keyboard user lands on an unnamed application.labelrenders the chart as a namedrole="figure".descriptioncarries the insight as a visually hidden summary, so a static read needs no interaction. State the numbers a sighted reader gets from the slices.- The centre text is decorative (
aria-hidden) because it repeats data the chart already carries — put the same number indescription. - Arrow keys step the tooltip through the slices and
Escapedismisses it without moving focus. - Colour is never the only encoding: the legend names every slice, and labels can print names or percentages on the chart itself.
- Chart colours are gated at 3:1 against the background (marks), tick and label text at 4.5:1.
API Reference
PieChart
| Prop | Type | Default | Description |
|---|---|---|---|
data | PieChartDatum[] | — | One row per slice. |
config | ChartConfig | — | Slice metadata (label, color), keyed by the nameKey value. |
dataKey | string | — | Datum key holding the numeric value. Warns in development if it is not a key of the first row. |
nameKey | string | — | Datum key holding the slice name. |
label | string | — | Accessible name; renders the chart as a role="figure". |
description | string | — | Visually hidden summary for assistive tech. |
donut | boolean | false | Sugar for innerRadius="55%". |
innerRadius | number | string | 0 | Hole size, px or a percentage of the radius. Overrides donut. |
outerRadius | number | string | "80%" | Outer radius, px or a percentage. |
centerValue | string | number | — | Big text in the middle of a donut. Ignored without a hole. |
centerLabel | string | — | Caption under centerValue. |
rounded | "none" | "sm" | "md" | "lg" | "full" | "none" | Slice corner rounding. full is the roundest the geometry allows. |
sliceGap | number | 0 | Angular gap between slices, in degrees. |
showSliceBorder | boolean | true | Hairline between adjacent slices, in the chart surface colour. |
minSliceAngle | number | 0 | Minimum angle for a non-zero slice, so a tiny share stays visible. |
startAngle | number | 90 | Where the first slice starts, in degrees. |
endAngle | number | -270 | Where the last slice ends. |
showLabels | boolean | false | Print a label per slice. |
labelType | "value" | "percent" | "name" | "name-percent" | "value" | What each label prints. |
labelPosition | "outside" | "inside" | "outside" | Outside with a leader line, or inside the slice. |
valueFormatter | (value, name) => string | number | — | Formats labels and the tooltip. |
sliceColor | (datum, index, name) => string | undefined | — | Per-slice fill override. |
showLegend | boolean | true | Render the legend. |
legendPosition | "top" | "bottom" | "bottom" | Legend placement. |
hiddenSlices | string[] | — | Slice names hidden by the consumer. Legend interactivity is controlled. |
onSliceToggle | (name: string) => void | — | Called when a legend item is clicked. |
onSliceClick | (datum, index, name) => void | — | Called when a slice is clicked. |
showTooltip | boolean | true | Render the tooltip. |
size | "sm" | "md" | "lg" | "md" | Diameter cap: 240 / 320 / 400px. |
dir | "ltr" | "rtl" | — | Direction; unset by default so a global dir is respected. |
loading | boolean | false | Render the skeleton. |
error | ReactNode | — | Render the error message instead of the chart. |
emptyState | ReactNode | "No data to display" | Shown with no rows, or when every value is zero. |
children | ReactNode | — | Extra Recharts elements drawn inside the chart. |
Data attributes
| Attribute | On | Value |
|---|---|---|
data-component | root | "pie-chart" |
data-slot | root | "chart" (from ChartContainer) |
data-slot | centre text | "pie-chart-center" |
Agent notes
roundedonly reads as rounded on a donut. With no hole the two straight edges of a slice meet at a point, so only the outer corners can round — this is inherited from d3 and is true of every charting library.- A zero-value slice draws nothing but keeps its legend row. Set
minSliceAngleto floor tiny non-zero values so they stay visible. - An all-zero series renders the empty state, not a blank circle: Recharts needs a positive total.
- A single 100% slice is a plain circle — Recharts ignores corner radius on a full-circle sector.
sliceGapis dropped by Recharts when there is only one slice. A full circle gets one gap per slice; an arc gets one fewer.- The legend is the interactive surface (
onSliceToggle), not the slice — that is what the field does, and it is reachable by keyboard.
Theming
Slice colours come from config, which normally points at the --chart-1
… --chart-6 tokens; a fill on a datum or a sliceColor callback overrides
per slice. The separator between slices paints --chart-surface, the same
token the stacked bars use. Centre text uses --foreground and
--muted-foreground.
Migration
- Slices now sweep clockwise from twelve o'clock. Recharts' own default
starts at three o'clock and runs counter-clockwise; pass
startAngle={0}andendAngle={360}to restore the previous rendering. - Adjacent slices now carry a hairline separator. Pass
showSliceBorder={false}for the previous gapless look. rounded="full"used to render identically torounded="none"on a non-donut pie; it now rounds the outer corners.