Donut Chart
betaA ring split into slices that show how parts make up a whole, with the total in the middle and every value in the legend.
Preview
five
two
currency
size
loads, then draws
Loading chart
loading
Loading chart
empty
No visitors yet
error
Visitors could not load.
Visitors could not load.
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. Add the component.
npx shadcn@latest add @txbrendan/donut-chart
Use it for
- Showing how two to five parts make up one whole, like visitors by browser or seats by plan.
- Pass data with one object per slice, the categoryKey that names each slice, and the valueKey that sizes it.
- Map slice names to labels and colors with config, like { chrome: { label: "Chrome" } }.
- Pass centerLabel to say what the total in the middle counts.
Avoid it for
- Comparing sizes that are close. People judge angles poorly. Use BarChart.
- More than five slices. The theme has five chart colors. Group the rest into Other.
- Values that do not add up to a whole, or negative values. Use BarChart.
- Showing change over time. Use LineChart or AreaChart.
Props
- data
One object per slice, like { browser: "chrome", visitors: 275 }.
TData[]
- categoryKey
The field that names each slice.
keyof TData
- valueKey
The number field that sizes each slice.
keyof TData
- config
Labels and colors by slice name. A slice it leaves out shows its name and takes the next chart color.
{ [slice: string]: { label: string; color?: string } }
- title
A visible title. It names the chart. TypeScript requires title, aria-label, or aria-labelledby.
ReactNode
- aria-label
Names the chart when it has no visible title.
string
- centerLabel
A word under the total in the middle, like "Visitors".
string
- size
Height: 5, 7, or 9 times --control-h-md, so density scales it. The ring and the total scale with it.
"sm" | "md" | "lg" · default "md"
- valueFormat
Formats values in the tooltip, the legend, and the middle.
Intl.NumberFormatOptions
- locale
The locale for numbers. Fixed, so server and browser print the same text.
string · default "en-US"
- loading
Shows a skeleton ring at the same size, and holds the legend with config names and placeholder values. When data arrives, the ring fades out while the slices draw in.
boolean · default false
- error
Shows this message in place of the chart. true shows a default message.
ReactNode
- onRetry
Shows a retry button under the error.
() => void
- emptyText
Shown when there is no data, or every value is 0.
ReactNode · default "No data yet"
- className
Extra classes. Later classes win, so className='h-96' replaces the height.
string
| Prop | Type | Default | What it does |
|---|---|---|---|
| data | TData[] | - | One object per slice, like { browser: "chrome", visitors: 275 }. |
| categoryKey | keyof TData | - | The field that names each slice. |
| valueKey | keyof TData | - | The number field that sizes each slice. |
| config | { [slice: string]: { label: string; color?: string } } | - | Labels and colors by slice name. A slice it leaves out shows its name and takes the next chart color. |
| title | ReactNode | - | A visible title. It names the chart. TypeScript requires title, aria-label, or aria-labelledby. |
| aria-label | string | - | Names the chart when it has no visible title. |
| centerLabel | string | - | A word under the total in the middle, like "Visitors". |
| size | "sm" | "md" | "lg" | "md" | Height: 5, 7, or 9 times --control-h-md, so density scales it. The ring and the total scale with it. |
| valueFormat | Intl.NumberFormatOptions | - | Formats values in the tooltip, the legend, and the middle. |
| locale | string | "en-US" | The locale for numbers. Fixed, so server and browser print the same text. |
| loading | boolean | false | Shows a skeleton ring at the same size, and holds the legend with config names and placeholder values. When data arrives, the ring fades out while the slices draw in. |
| error | ReactNode | - | Shows this message in place of the chart. true shows a default message. |
| onRetry | () => void | - | Shows a retry button under the error. |
| emptyText | ReactNode | "No data yet" | Shown when there is no data, or every value is 0. |
| className | string | - | Extra classes. Later classes win, so className='h-96' replaces the height. |
Accessibility
- Renders a <figure> named by its title, or by aria-label. TypeScript requires one.
- The legend is a list of every slice with its value in text, so the numbers do not depend on color or angle.
- The total in the middle is text, and screen readers read it after the chart.
- Recharts' keyboard layer stays on: Tab focuses the chart, the arrow keys move between slices, and the tooltip reads each one out. The ring is one tab stop.
- Keeps one height while it loads, draws, fails, or has no data, so the page does not move. When the page prints, it asks the browser to keep a chart that fits on one page whole. In the light theme, it asks the browser to print legend dots and loading shapes even with backgrounds off.
- Slice colors come from --chart-1 to --chart-5, which the theme tests hold to 3:1 against the page.
- Slices sweep in over three times --motion-duration-base, once the chart is on screen. They finish at once when the page prints. At 0ms they draw at once, and Recharts skips the animation for reduced motion.
Examples
Slices with a label in the center
<DonutChart
title="Visitors by browser"
data={[
{ browser: "chrome", visitors: 275 },
{ browser: "safari", visitors: 200 },
{ browser: "firefox", visitors: 187 },
]}
categoryKey="browser"
valueKey="visitors"
config={{ chrome: { label: "Chrome" }, safari: { label: "Safari" }, firefox: { label: "Firefox" } }}
centerLabel="Visitors"
/>Currency
<DonutChart
title="Ad spend by channel"
data={[
{ channel: "Search", amount: 48200 },
{ channel: "Social", amount: 31750 },
]}
categoryKey="channel"
valueKey="amount"
centerLabel="Spent"
valueFormat={{ style: "currency", currency: "USD", maximumFractionDigits: 0 }}
/>While it loads, and when it fails
<DonutChart
aria-label="Visitors by browser"
data={data ?? []}
categoryKey="browser"
valueKey="visitors"
loading={isLoading}
error={error ? "Visitors could not load." : undefined}
onRetry={() => refetch()}
/>