Pie

Pie and donut charts split a whole into slices, to show how each part contributes to a total.

LLMs

Code

Pie and donut charts are drawn with TanStack Charts, through the kit's Chart components. They paint the chart with the theme's colors, grid and type, and add three palette props. The chart itself is a TanStack definition, written the way TanStack documents it.

Install

npx antuikit@latest init
npx antuikit@latest add tanstack-chart
The first command installs AntuikitProvider and the theme tokens. The second adds the chart components and TanStack Charts itself.

Your first pie chart

Pass your rows through pie, which turns each row into a slice with a start and an end angle. Draw the slices with radialArc inside a polar container. A pie has no axes, so every scale is null.
import { defineChart } from "@tanstack/charts"
import { pie, polar, radialArc } from "@tanstack/charts/polar"
import { tooltip } from "@tanstack/charts/tooltip"
import { Chart, chartColors } from "@/components/ui/tanstack-chart"
const browsers = [
{ browser: "Chrome", visitors: 275 },
{ browser: "Safari", visitors: 200 },
{ browser: "Firefox", visitors: 187 },
{ browser: "Edge", visitors: 173 },
{ browser: "Other", visitors: 90 },
]
const definition = defineChart(
{
marks: [
polar({
radiusRatio: 0.8,
scales: { angle: null, radius: null },
marks: [
radialArc(pie(browsers, { value: "visitors" }), {
key: "browser",
color: "browser",
stroke: chartColors.surface,
strokeWidth: 1,
}),
],
}),
],
scales: { x: null, y: null },
color: { domain: browsers.map((row) => row.browser) },
margin: 0,
},
{
tooltip: {
use: tooltip,
format: ({ datum }) => `${datum.browser} · ${datum.visitors} visitors`,
},
},
)
export function BrowsersChart() {
return (
<Chart
definition={definition}
ariaLabel="Visitors by browser, June"
height={220}
/>
)
}
The chart fills its container's width, so give it a height. ariaLabel is required.

Which component

All three take the same definition and the same palette props.
Chart components

Start angle and direction

pie starts at 0, which is 12 o'clock, and goes clockwise to 2π. That is the order readers expect, so you rarely change it. A startAngle larger than the endAngle sweeps counterclockwise.
// From 3 o'clock, counterclockwise.
pie(browsers, {
value: "visitors",
startAngle: Math.PI / 2,
endAngle: (-Math.PI * 3) / 2,
})

Sorting and Other

Slices follow the order of your rows. Sort them with orderBy and order, and fold the smallest rows into one Other row before you call pie, so it stays last.
const sorted = [...rows].sort((a, b) => b.visitors - a.visitors)
const top = sorted.slice(0, 4)
const other = sorted.slice(4).reduce((sum, row) => sum + row.visitors, 0)
const browsers = other > 0 ? [...top, { browser: "Other", visitors: other }] : top
// Or let pie sort the slices.
pie(rows, { value: "visitors", orderBy: "visitors", order: "descending" })
Give each slice a key from a field of your rows, such as the name. The slice's position changes when the data does, so an index makes slices animate into each other.

Donut

Set innerRadius on radialArc. A number is a fixed hole in pixels. A function of the layout keeps the hole the same share of the chart at any size.
radialArc(pie(uses, { value: "kwh" }), {
key: "use",
color: "use",
innerRadius: ({ radius }) => radius * 0.58,
})

Total in the center

Add radialText marks at angle 0 and radius 0, and nudge the lines apart with dy. Text needs the angle and radius scales, so they can no longer be null.
import { scaleLinear } from "@tanstack/charts/scales/linear"
polar({
radiusRatio: 0.78,
scales: {
angle: { scale: scaleLinear().domain([0, Math.PI * 2]) },
radius: { scale: scaleLinear().domain([0, 1]) },
},
marks: [
radialArc(arcs, { key: "plan", color: "plan", innerRadius: 60 }),
radialText([{ id: "total", angle: 0, radius: 0, text: "925" }], {
angle: "angle",
radius: "radius",
key: "id",
text: "text",
dy: -5,
fill: chartColors.text,
fontSize: 30,
fontWeight: 700,
}),
radialText([{ id: "label", angle: 0, radius: 0, text: "Signups" }], {
angle: "angle",
radius: "radius",
key: "id",
text: "text",
dy: 19,
fill: chartColors.label,
fontSize: 14,
}),
],
})

Slice labels

Every row pie returns carries angle, the middle of its slice, and fraction, its share of the total. Place a radialText on that angle for the label and a radialRule for the leader line. With the radius scale from 0 to 1, a radius of 1 is the pie's edge.
const percent = new Intl.NumberFormat("en-US", { style: "percent" })
const arcs = pie(channels, { value: "tickets" })
radialRule(arcs, {
angle: "angle",
radius1: 1,
radius2: 1,
radius2Offset: 14,
key: "channel",
stroke: chartColors.axis,
}),
radialText(arcs, {
angle: "angle",
radius: 1,
radiusOffset: 17,
text: (row) => percent.format(row.fraction),
key: "channel",
anchor: "outside",
fill: chartColors.text,
fontSize: 12,
})
  • anchor: "outside" sets each label's side from its angle, so labels on the left end at the leader line.
  • Offsets do not reserve space. Lower radiusRatio on polar until the labels fit inside the chart.
  • For labels inside the slices, use a radius below 1, such as 0.68, with anchor: "middle" and a fill in chartColors.surface.

Gaps and rounded ends

gapAngle on pie leaves empty space between the slices, in radians. cornerRadius on radialArc rounds their corners. Use either the gap or a stroke separator, not both.
radialArc(pie(stages, { value: "deals", gapAngle: (Math.PI / 180) * 3 }), {
key: "stage",
color: "stage",
innerRadius: ({ radius }) => radius * 0.58,
cornerRadius: 8,
})

Pulling out one slice

Draw the slice a second time, in its own radialArc, with a larger outerRadius. Rebuild the definition when the chosen slice changes.
radialArc(arcs.slice(0, 1), {
id: "active-browser",
key: "browser",
color: "browser",
innerRadius: 60,
outerRadius: ({ radius }) => radius + 10,
})

Legend

Labels on the slices read best. When there is no room for them, add a legend to the color scale with colorLegend and colorLegendItems, and leave a bottom margin for it.
import { colorLegend, colorLegendItems } from "@tanstack/charts"
color: {
domain: channels.map((row) => row.channel),
legend: colorLegend<string>({
placement: "bottom",
items: colorLegendItems({ justify: "center", gap: 12 }),
}),
},
margin: { top: 0, right: 0, left: 0 },

Colors

The chart takes the theme's chart colors without any setup. Change the palette on the chart itself, or paint one mark with fill and chartColors. The Color guideline covers which palette fits which data.
<Chart definition={definition} ariaLabel="..." palette="multi" />
// Set the color domain so each name keeps its color when the data changes.
color: { domain: browsers.map((row) => row.browser) }
Palette props
Taken by Chart, RendererChart and CanvasChart. Rows marked antuikit in the Version column are added by the kit.

Tooltip and focus

Pass focus: focusGroupAngle to snap focus to the slice under the pointer's angle, and the arrow keys move around the pie in angular order. In the tooltip's content, point.datum is the row from pie, with your own fields and its fraction.

Animation

Use RendererChart with TanStack's motion() renderer to animate marks in and between data updates. It skips the animation when the reader has asked for reduced motion.
import { motion } from "@tanstack/charts/motion"
import { RendererChart } from "@/components/ui/tanstack-chart"
const renderer = motion({ initial: "always" })
<RendererChart definition={definition} renderer={renderer} ariaLabel="..." />

API

The options a pie or donut chart uses most. TanStack Charts' documentation has the full reference.
pie
Fields pie adds
radialArc
radialText and radialRule
polar