DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
ChartArea ChartBar ChartLine ChartLive Line ChartComposed ChartPie ChartFunnel ChartRadar ChartRadial ChartSankey ChartScatter ChartCandlestick ChartSunburst ChartChoropleth ChartActivity Heatmap
AI agentsContributing
  1. Components
  2. Pie Chart

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/ui

When 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 Other slice (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 donut whenever 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

Terminal
pnpm add @tessinaui/ui recharts

Usage

example.tsx
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

PropTypeDefaultDescription
dataPieChartDatum[]—One row per slice.
configChartConfig—Slice metadata (label, color), keyed by the nameKey value.
dataKeystring—Datum key holding the numeric value. Warns in development if it is not a key of the first row.
nameKeystring—Datum key holding the slice name.
labelstring—Accessible name; renders the chart as a role="figure".
descriptionstring—Visually hidden summary for assistive tech.
donutbooleanfalseSugar for innerRadius="55%".
innerRadiusnumber | string0Hole size, px or a percentage of the radius. Overrides donut.
outerRadiusnumber | string"80%", or "60%" with outside labelsOuter 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.
centerValuestring | number—Big text in the middle of a donut. Ignored without a hole.
centerLabelstring—Caption under centerValue.
rounded"none" | "sm" | "md" | "lg" | "full""none"Slice corner rounding. full is the roundest the geometry allows.
sliceGapnumber0Angular gap between slices, in degrees.
showSliceBorderbooleantrueHairline between adjacent slices, in the colour behind the chart.
minSliceAnglenumber0Minimum angle for a non-zero slice, so a tiny share stays visible.
startAnglenumber90Where the first slice starts, in degrees.
endAnglenumber-270Where the last slice ends.
showLabelsbooleanfalsePrint 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.
showLegendbooleantrueRender the legend.
legendPosition"top" | "bottom""bottom"Legend placement.
hiddenSlicesstring[]—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.
selectedstring | null—Name of the chosen slice, controlled. null chooses nothing.
defaultSelectedstring | nullnullName 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".
activestring | 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.
stringsPartial<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.
showTooltipbooleantrueRender 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.
loadingbooleanfalseRender the skeleton.
errorReactNode—Render the error message instead of the chart.
emptyStateReactNode"No data to display"Shown with no rows, or when every value is zero.
childrenReactNode—Extra Recharts elements drawn inside the chart.

Data attributes

AttributeOnValue
data-componentroot"pie-chart"
data-slotroot"chart" (from ChartContainer)
data-slotcentre text"pie-chart-center"
data-chart-itemeach slice, while the chart selects or is lit—
data-selected / data-activethe chosen / lit slice—
data-slotthe chosen slice's ring"chart-selection-ring"
data-slotthe 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.

  • label is the accessible name, and it is not optional in practice. Recharts makes the chart svg a keyboard-focusable role="application" surface; without label the root carries no role at all, so aria-label on it is ignored and a keyboard user lands on an unnamed application. label renders the chart as a named role="figure".
  • description carries 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 in description.
  • Arrow keys step the tooltip through the slices and Escape dismisses it without moving focus.
  • With selectionMode="single" the slices become a list to choose from. The chart's one tab stop is a named listbox ("Browser share: Slices"), each slice an option with aria-selected and a name that carries its value and share, and the slice the arrow keys are on is its aria-activedescendant. Enter or Space chooses that slice, so Enter no longer toggles the tooltip; Escape still 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

  • rounded only 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 minSliceAngle to 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 outerRadius smaller, 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.
  • sliceGap is 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} and endAngle={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 to rounded="none" on a non-donut pie; it now rounds the outer corners.
Composed ChartFunnel Chart

On this page

When to useWhen not to useInstallationUsageExamplesDefaultDonut with a totalRingGaps and rounded capsHalf donutCorner roundingPercent labelsLabels inside the sliceValue formattingChoosing a sliceLinked to a listInteractive legendBucketing the tail into "Other"MinimalAccessible figureOn a coloured plateStatesAPI ReferencePieChartData attributesAccessibilityAgent notesThemingMigration