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.
pnpm add @tessinaui/uiWhen to use
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.
- Parts of one total. The slices must sum to a meaningful whole, such as tickets by status or spend by category.
- A few slices. Five or fewer. Past that, bucket the tail into an
Otherslice (see the example below) or switch to a bar chart. - One snapshot in time. A pie summarises a single moment, not a trend.
- A number worth stating in the middle. A donut buys you the centre, which
is why most real donuts put the total there. Reach for
donutwhenever you have one. - A share the reader can also read as text. Print values or percentages on the slices, or name them in the legend, so no one has to judge an angle.
When not to use
- Comparing categories precisely. Use BarChart.
- Change over time. Use LineChart or AreaChart.
- One value against a target. Use Meter or Progress.
- Concentric progress rings. Use RadialChart.
- Parts that have parts of their own. Revenue by product, then plan, then region is a hierarchy; use SunburstChart.
- Values that do not sum to a whole, or that include negatives. 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. Use a bar chart.
- Many slices or near-equal shares. The reader cannot rank them by angle. Bucket the tail or use a bar chart.
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."
/>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
A label inside a slice prints in --background. In the light theme that holds
4.5:1 only over --chart-1, --chart-2 and a neutral such as
--muted-foreground; over the other slice colours, print labels outside the
ring.
Value formatting
Choosing a slice
With selectionMode="single" a click, a tap, or Enter or Space on the slice
the arrow keys are on chooses it, and choosing it again clears it. The chosen
slice wears a ring; the others step back to a wash inside an outline. Here the
centre names the chosen slice, and a button below clears it.
Linked to a list
active lights a slice from outside the chart, and onActiveChange reports
the slice under the chart's own pointer or arrow keys, so a list beside the
chart and the chart light each other. Lighting is not choosing: here choosing a
row chooses its slice too.
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
On a coloured plate
Inside a coloured Surface the chart's chrome takes the plate's ink with no
prop: labels, centre text, the focus ring, and the seams between slices, which
are drawn in the plate's own colour. Slice colours are yours. The --chart-N
tokens are measured against the page, not a plate, so give each slice the
plate's ink (hsl(var(--surface-ink))). Only the full ink and 80% of it clear
3:1 on every plate, so alternate neighbours between the two and let labels name
each slice. A label inside a slice reads in --surface-on-ink and holds 4.5:1
over either strength. The tooltip is a page panel, so it keeps the page's look.
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.
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%", or "60%" with outside labels | Outer radius, px or a percentage. Outside labels need room inside the chart's box: 60% fits about five characters, so pass a smaller radius for longer labels. |
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 colour behind the chart. |
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. |
selectionMode | "none" | "single" | "none" | "single" lets a reader choose one slice by click, tap, Enter or Space; choosing it again clears it. |
selected | string | null | — | Name of the chosen slice, controlled. null chooses nothing. |
defaultSelected | string | null | null | Name of the slice chosen at first render, uncontrolled. |
onSelectedChange | (key, { datum, index, source }) => void | — | Called on every choice and clear; source is "pointer" or "keyboard". |
active | string | null | — | Name of the lit slice, controlled — to light it from a list or legend. |
onActiveChange | (key: string | null) => void | — | Called when the chart's own pointer or arrow keys move to another slice. |
strings | Partial<PieChartStrings> | — | Selection's words per entry: selected, cleared, items, slice. Arabic under dir="rtl". |
onSliceClick | (datum, index, name) => void | — | Deprecated: use selectionMode with onSelectedChange, which the keyboard reaches too. Still fires on a pointer click. |
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" |
data-chart-item | each slice, while the chart selects or is lit | — |
data-selected / data-active | the chosen / lit slice | — |
data-slot | the chosen slice's ring | "chart-selection-ring" |
data-slot | the status region | "chart-announcer" |
Accessibility
VoiceOver + Safari: passed 2026-09-14. A static read gives "Most tickets are already resolved, figure" and the summary, "Of 252 tickets this week, 184 are resolved, 46 are open and 22 are waiting on the customer." Tab lands on the plot, which VoiceOver reads after the figure name as "web application", and the arrow keys read each point through the tooltip ("Open, 46"). NVDA and JAWS are untested.
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. - With
selectionMode="single"the slices become a list to choose from. The chart's one tab stop is a namedlistbox("Browser share: Slices"), each slice anoptionwitharia-selectedand a name that carries its value and share, and the slice the arrow keys are on is itsaria-activedescendant. Enter or Space chooses that slice, so Enter no longer toggles the tooltip;Escapestill hides it. A polite status region says "Chrome selected" or "Selection cleared" once per change. The screen reader pass of this mode is still to be run. - 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.
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.
- The chart clips anything past its own box. An outside label starts 20px
beyond the rim, so the default ring drops to 60% while labels print outside,
which fits about five characters at the default size. For names, shorten them
and set
outerRadiussmaller, or name the slices in the legend instead. - 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 --surface-fill, the colour
behind the chart, which the page, a card and a coloured plate each publish; the
stacked bars use the same seam. Labels outside the ring and the centre text use
--foreground and --muted-foreground, and a label inside a slice uses
--background. On a coloured plate they take --surface-ink, its muted tier
and --surface-on-ink instead.
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. - Outside labels now shrink the default ring to 60%. At 80% the chart's own
edge cut the labels at three and nine o'clock; pass
outerRadius="80%"for the previous size. - 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.