Pie Chart
One total, divided between its parts.
'use client';
import { useState } from 'react';
import { PieChart } from '@/components/panelui/pie-chart';
import { Frame } from '@/components/panelui/frame';
const SPEND = [
{ label: 'Rent', value: 1450 },
{ label: 'Food', value: 620 },
{ label: 'Transport', value: 210 },
{ label: 'Utilities', value: 185 },
{ label: 'Everything else', value: 240 },
];
const total = SPEND.reduce((sum, slice) => sum + slice.value, 0);
const dollars = (value: number) => `$${value.toLocaleString('en-US')}`;
export default function PieChartOverview() {
const [active, setActive] = useState(-1);
const slice = active >= 0 ? SPEND[active] : undefined;
return (
<Frame variant="band" className="w-full max-w-sm">
<Frame.Header>
<Frame.Title>Spending</Frame.Title>
<Frame.Action>August</Frame.Action>
</Frame.Header>
<Frame.Panel className="p-5">
<PieChart data={SPEND} size={208} innerRadius={0.62} onActiveIndexChange={setActive}>
<PieChart.Header
title={slice ? slice.label : 'All categories'}
value={dollars(slice ? slice.value : total)}
/>
<PieChart.Slices />
<PieChart.Center formatValue={dollars} />
<PieChart.Legend />
</PieChart>
</Frame.Panel>
</Frame>
);
}Use it for a handful of parts of one obvious total: a budget, a disk, a split of traffic. Every slice is a share of the sum of data, so there is no maximum to set.
Angles are hard to compare, and a pie stops working at around six or seven slices. Sort the parts, keep the top few and put the rest in an "everything else" slice.
For parts that need precise comparison, or that do not belong to one whole, use a Bar Chart.
Installation
npx shadcn@latest add https://panelui.dev/r/web/pie-chart.jsonpnpm dlx shadcn@latest add https://panelui.dev/r/web/pie-chart.jsonyarn dlx shadcn@latest add https://panelui.dev/r/web/pie-chart.jsonbunx shadcn@latest add https://panelui.dev/r/web/pie-chart.jsonThe source is copied into components/panelui/, where you can edit it.
Usage
import { PieChart } from "@/components/panelui/pie-chart"<PieChart data={spend} size={208} innerRadius={0.62}>
<PieChart.Header title="August" />
<PieChart.Slices />
<PieChart.Center />
<PieChart.Legend />
</PieChart>Composition
<PieChart data={…}>
<PieChart.Header />
<PieChart.Skeleton />
<PieChart.Slices />
<PieChart.Center />
<PieChart.Legend />
</PieChart>PieChart.Header— The strip above the dial:title,valueandcaption, with a colour key or your own control on the trailing side.PieChart.Slices— Every slice, in data order. The chart draws nothing without this part. Its props set the rounding, the lift and the dimming.PieChart.Center— The readout in the hole: the total, then the value and share of the slice being read. It draws nothing wheninnerRadiusis0.PieChart.Legend— A swatch, a name and a share per slice, under the dial. Each entry is a button that reads its slice.PieChart.Skeleton— The dial as one plain band whilestatusisloading. It fades out as the slices unroll over it.
Examples
Pie or donut
innerRadius is a share of the radius, and 0 is a pie. A donut has room for PieChart.Center, which shows the total, the one figure a pie can be read for exactly.
'use client';
import { PieChart } from '@/components/panelui/pie-chart';
const SPEND = [
{ label: 'Rent', value: 1450 },
{ label: 'Food', value: 620 },
{ label: 'Transport', value: 210 },
{ label: 'Utilities', value: 185 },
];
export default function PieChartPie() {
return (
<PieChart data={SPEND} className="max-w-60">
<PieChart.Slices />
<PieChart.Legend />
</PieChart>
);
}Reading from outside
Pass activeIndex and onActiveIndexChange when something else on the page has to stay in step. The callback fires for hovering as well as pressing, and with -1 when the reading ends.
'use client';
import { useState } from 'react';
import { PieChart } from '@/components/panelui/pie-chart';
import { Button } from '@/components/panelui/button';
const SPEND = [
{ label: 'Rent', value: 1450 },
{ label: 'Food', value: 620 },
{ label: 'Transport', value: 210 },
{ label: 'Everything else', value: 240 },
];
export default function PieChartSelecting() {
const [active, setActive] = useState(-1);
return (
<div className="flex w-full max-w-sm flex-col items-start gap-4">
<PieChart data={SPEND} size={208} innerRadius={0.62} activeIndex={active} onActiveIndexChange={setActive}>
<PieChart.Slices />
<PieChart.Center />
<PieChart.Legend />
</PieChart>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setActive(0)}>
Show rent
</Button>
<Button variant="ghost" onClick={() => setActive(-1)}>
Clear
</Button>
</div>
</div>
);
}A short key
legend on the header puts the key in its trailing corner. Use it for two or three short names, and PieChart.Legend for anything longer.
'use client';
import { PieChart } from '@/components/panelui/pie-chart';
const STORAGE = [
{ label: 'Photos', value: 82, color: 'chart-1' as const },
{ label: 'Apps', value: 41, color: 'chart-2' as const },
];
export default function PieChartKey() {
return (
<PieChart data={STORAGE} size={180} innerRadius={0.62} className="max-w-sm">
<PieChart.Header title="Storage" value="123 GB" legend />
<PieChart.Slices />
</PieChart>
);
}Gaps, rounding and a floor
padAngle leaves a gap between slices and cornerRadius rounds them. minAngle keeps a very small slice visible: Email is a third of a percent here. The angle it gains comes off the other slices, so its size no longer matches its share exactly.
'use client';
import { PieChart } from '@/components/panelui/pie-chart';
const SOURCES = [
{ label: 'Search', value: 6420 },
{ label: 'Direct', value: 3180 },
{ label: 'Social', value: 1240 },
{ label: 'Email', value: 36 },
];
export default function PieChartSegments() {
return (
<PieChart data={SOURCES} size={208} innerRadius={0.58} padAngle={3} minAngle={6} className="max-w-sm">
<PieChart.Slices cornerRadius={6} />
<PieChart.Center defaultLabel="Sessions" />
<PieChart.Legend />
</PieChart>
);
}Opening the dial
startAngle and endAngle are degrees clockwise from twelve o'clock. Less than a full turn leaves a gap, and the slices share what remains.
'use client';
import { PieChart } from '@/components/panelui/pie-chart';
const STORAGE = [
{ label: 'Photos', value: 82 },
{ label: 'Apps', value: 41 },
{ label: 'System', value: 18 },
];
export default function PieChartDial() {
return (
<PieChart
data={STORAGE}
size={208}
innerRadius={0.66}
startAngle={-135}
endAngle={135}
padAngle={2}
className="max-w-sm"
>
<PieChart.Slices cornerRadius={4} />
<PieChart.Center defaultLabel="Used" formatValue={(gb) => `${gb.toFixed(0)} GB`} />
<PieChart.Legend />
</PieChart>
);
}Drawing the middle yourself
PieChart.Center takes a function, given the slice being read or null. Its width is limited to the square that fits inside the hole.
'use client';
import { PieChart } from '@/components/panelui/pie-chart';
import { Stat } from '@/components/panelui/stat';
const SPEND = [
{ label: 'Rent', value: 1450 },
{ label: 'Food', value: 620 },
{ label: 'Transport', value: 210 },
];
const total = SPEND.reduce((sum, slice) => sum + slice.value, 0);
export default function PieChartCenterDemo() {
return (
<PieChart data={SPEND} size={208} innerRadius={0.62} className="max-w-sm">
<PieChart.Slices />
<PieChart.Center>
{(slice) => (
<>
<Stat.Label>{slice ? slice.label : 'August'}</Stat.Label>
<Stat.Value size="sm">${(slice ? slice.value : total).toLocaleString('en-US')}</Stat.Value>
</>
)}
</PieChart.Center>
</PieChart>
);
}Loading, then data
Add PieChart.Skeleton and drive status. The pie unrolls clockwise when it turns ready.
'use client';
import { useState } from 'react';
import { PieChart, type PieChartStatus } from '@/components/panelui/pie-chart';
import { Button } from '@/components/panelui/button';
const SPEND = [
{ label: 'Rent', value: 1450 },
{ label: 'Food', value: 620 },
{ label: 'Transport', value: 210 },
{ label: 'Everything else', value: 240 },
];
export default function PieChartLoading() {
const [status, setStatus] = useState<PieChartStatus>('loading');
return (
<div className="flex w-full max-w-sm flex-col items-start gap-4">
<PieChart data={SPEND} status={status} size={208} innerRadius={0.62}>
<PieChart.Skeleton />
<PieChart.Slices />
<PieChart.Center />
</PieChart>
<Button variant="outline" onClick={() => setStatus(status === 'loading' ? 'ready' : 'loading')}>
{status === 'loading' ? 'Finish loading' : 'Load again'}
</Button>
</div>
);
}Changing data
When data changes, the slices move to their new angles. A ref on the chart has replay(), which unrolls the pie again.
'use client';
import { useRef, useState } from 'react';
import { PieChart, type PieChartHandle } from '@/components/panelui/pie-chart';
import { Button } from '@/components/panelui/button';
const NAMES = ['Rent', 'Food', 'Transport', 'Everything else'];
const MONTHS = {
August: [1450, 620, 210, 240],
July: [1450, 410, 480, 690],
};
type Month = keyof typeof MONTHS;
export default function PieChartChangingData() {
const chart = useRef<PieChartHandle>(null);
const [month, setMonth] = useState<Month>('August');
const rows = NAMES.map((label, index) => ({ label, value: MONTHS[month][index] ?? 0 }));
return (
<div className="flex w-full max-w-sm flex-col items-start gap-4">
<PieChart ref={chart} data={rows} size={208} innerRadius={0.62}>
<PieChart.Slices />
<PieChart.Center defaultLabel={month} />
<PieChart.Legend />
</PieChart>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setMonth(month === 'August' ? 'July' : 'August')}>
Show {month === 'August' ? 'July' : 'August'}
</Button>
<Button variant="ghost" onClick={() => chart.current?.replay()}>
Replay
</Button>
</div>
</div>
);
}API Reference
PieChart
| Prop | Type | Default | Description |
|---|---|---|---|
data | readonly PieDatum[] | — | One entry per slice, in the order they are drawn clockwise. |
size | number | — | Fixed diameter in pixels. Fills the container's width as a square when omitted. |
innerRadius | number | 0 | The hole, as a share of the radius. 0 is a pie. Anything above it is a donut, and 0.55 to 0.65 leaves room for a readout in the middle. |
startAngle | number | 0 | Where the first slice begins, in degrees clockwise from twelve o'clock. |
endAngle | number | 360 | Where the last one ends, on the same clock. A turn between the two closes the pie, and anything less leaves a gap and reads as a dial. |
padAngle | number | 0 | Gap between one slice and the next, in degrees. |
minAngle | number | 0 | The smallest angle any non-zero slice is drawn at, in degrees. The angle it borrows comes off the others in proportion, so the turn still closes. |
animationDuration | number | 620 | Milliseconds for the pie to unroll. |
status | PieChartStatus | 'ready' | loading holds the slices back and unrolls them when it turns ready. |
activeIndex | number | — | The slice being read. Leave unset to let the chart track it. |
onActiveIndexChange | (index: number) => void | — | Fires with the slice being read, or -1 when there is none. |
label | string | — | What a screen reader calls the chart. |
labelForDatum | (datum: PieDatum, index: number) => string | — | What a screen reader says for one slice. Defaults to its name and share. |
PieChart.Slices
| Prop | Type | Default | Description |
|---|---|---|---|
cornerRadius | number | — | Rounds the four turns of each slice, in pixels. |
popOut | number | — | How far a slice lifts out of the pie while it is read, in pixels. |
dimOpacity | number | — | Opacity of the other slices while one is read. |
PieChart.Skeleton
| Prop | Type | Default | Description |
|---|---|---|---|
color | PieChartColor | — | Which of the theme's roles to draw the band in. Defaults to the muted surface. |
PieChart.Center
| Prop | Type | Default | Description |
|---|---|---|---|
defaultLabel | string | — | Heading shown when no slice is read. |
formatValue | (value: number, slice: PieDatum | null) => string | — | Format the number under the label. Defaults to a compact number. |
className | string | — | Added to the readout. |
PieChart.Legend
| Prop | Type | Default | Description |
|---|---|---|---|
showValue | boolean | — | Show each slice's share of the whole beside its name. |
PieChart.Header
| Prop | Type | Default | Description |
|---|---|---|---|
title | ReactNode | — | Small line above the value: what the chart is of. |
value | ReactNode | — | The readout. The largest thing on the card, and the first thing read. |
caption | ReactNode | — | One muted line under the value: a period, a comparison, a caveat. |
labels | Record<string, string> | — | Prettier names for the slices, keyed by their label. |
legend | boolean | false | Draw a swatch and a name per slice along the trailing edge, for two or three short names. Past that use PieChart.Legend, which has the whole width. |
PieChart, PieChart.Header, PieChart.Legend and PieChart.Skeleton also take the props of the element they render, and a className. PieChart.Center takes a className for its readout. PieChart.Slices draws nothing itself and takes only the props listed.
Notes
Sizing
The chart takes size as its diameter, or fills its container's width as a square without it. The plot draws in the browser after it has been measured, so it is not in server-rendered HTML.
Colour
color on a datum names a role in the theme. Without it, slices take the five chart colours in order, and a sixth slice repeats the first.
Reading a slice
Hovering a slice reads it. A click or a tap holds the reading until you press that slice again, press outside the slices, or press Escape. On a touch screen a drag scrolls the page and does not read slices.
Inside the chart, usePieChart gives the slice being read and its share.
Keyboard
The plot takes focus. The arrow keys move between slices, Enter or Space holds the reading, and Escape clears it. Moving to another slice lets go of a held reading. The legend's entries are buttons, reached with Tab.
Screen readers
Each slice is given as text beside the drawing, as its name and share, and the reading is announced as it changes. label names the chart and labelForDatum writes a slice, for values with a unit.
Reduced motion
With reduced motion set, the pie appears without unrolling and the data change does not animate. The lift on a slice being read stays, because it is the response to the pointer or a press.
Zero and negative values
A negative value counts as zero, and a slice worth zero is not drawn, whatever minAngle says. It keeps its legend entry.
Server Components
PieChart is a client component. In a file that starts with "use client", write its parts with a dot, as on this page. A Server Component cannot read PieChart.Header off the import, so each part is also exported by name: PieChartHeader is PieChart.Header, and so on for the others.
import { PieChart, PieChartHeader, PieChartSlices, PieChartCenter, PieChartLegend, PieChartSkeleton } from "@/components/panelui/pie-chart"