Donut chart
A donut chart in plain SVG with the total in the middle and a legend with the numbers.
npx shadcn@latest add https://ui.goodtabs.co/r/donut-chart.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/donut-chart
200Items
- Components118 59%
- Blocks46 23%
- Hooks22 11%
- Utilities14 7%
Slices in foreground tones ranked by size, total in the middle.
One slice highlighted
12,200Visits
- Direct5,400 44%
- Search3,800 31%
- Referral2,100 17%
- Social900 7%
The brand colour on the slice the sentence is about.
Usage
import { DonutChart } from "@/components/ui/donut-chart"
<DonutChart title="Visits by source" data={[{ label: "Direct", value: 54 }, { label: "Search", value: 38 }]} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| data | { label: string; value: number }[] | The parts of the whole. Up to five read well. | |
| title | string | What the chart shows, for screen readers. | |
| totalLabel | string | "Total" | Small label under the total. |
| highlight | number | Index of the one slice drawn in the brand colour. | |
| format | (value: number) => string | String | Formats the total and the legend values. |
Source
donut-chart/donut-chart.tsx
import * as React from "react";
import { cn } from "@/lib/utils";
type Slice = { label: string; value: number };
type DonutChartProps = Omit<React.ComponentProps<"figure">, "title"> & {
/** The parts of the whole. */
data: Slice[];
/** What the chart shows, read by screen readers. */
title: string;
/** Small label under the total in the middle. */
totalLabel?: string;
/** Index of the one slice to draw in the brand colour. */
highlight?: number;
/** Format a value for the legend and the total. */
format?: (value: number) => string;
};
const R = 40;
const C = 2 * Math.PI * R;
const GAP = 1.5;
/** Foreground opacity per rank, strongest first. */
const TONES = [
"stroke-foreground/85",
"stroke-foreground/55",
"stroke-foreground/35",
"stroke-foreground/20",
"stroke-foreground/10",
];
const SWATCH = ["bg-foreground/85", "bg-foreground/55", "bg-foreground/35", "bg-foreground/20", "bg-foreground/10"];
/**
* A donut chart in plain SVG with the total in the middle and a legend that carries the numbers.
* Server component. Slices are ranked by size, so tone follows value rather than order.
*/
function DonutChart({
data,
title,
totalLabel = "Total",
highlight,
format = String,
className,
...props
}: DonutChartProps) {
const total = data.reduce((sum, d) => sum + d.value, 0);
const rank = [...data.keys()].sort((a, b) => data[b].value - data[a].value);
const arcs = data.map((d, i) => ({
length: (d.value / total) * C,
offset: (data.slice(0, i).reduce((sum, p) => sum + p.value, 0) / total) * C,
tone: Math.min(rank.indexOf(i), TONES.length - 1),
}));
return (
<figure data-slot="donut-chart" className={cn("m-0 flex w-full flex-col items-center gap-4", className)} {...props}>
<div className="relative aspect-square w-full max-w-40">
<svg viewBox="0 0 100 100" className="block size-full -rotate-90" aria-hidden="true">
<circle cx={50} cy={50} r={R} className="fill-none stroke-border" strokeWidth={10} />
{arcs.map((a, i) => (
<circle
key={data[i].label}
cx={50}
cy={50}
r={R}
fill="none"
strokeWidth={10}
strokeDasharray={`${Math.max(a.length - GAP, 0)} ${C}`}
strokeDashoffset={-a.offset}
className={i === highlight ? "stroke-brand" : TONES[a.tone]}
/>
))}
</svg>
<div className="absolute inset-0 grid place-content-center text-center">
<span className="text-2xl leading-none font-medium tracking-tight">{format(total)}</span>
<span className="mt-1 font-mono text-[10px] tracking-wide text-muted-foreground uppercase">{totalLabel}</span>
</div>
</div>
<figcaption className="w-full">
<span className="sr-only">{title}</span>
<ul className="m-0 flex w-full list-none flex-col gap-1.5 p-0 text-sm">
{data.map((d, i) => (
<li key={d.label} className="flex items-center gap-2">
<span
aria-hidden="true"
className={cn("size-2 shrink-0 rounded-full", i === highlight ? "bg-brand" : SWATCH[arcs[i].tone])}
/>
<span className="min-w-0 flex-1 truncate">{d.label}</span>
<span className="font-mono text-xs text-foreground/70 tabular-nums">
{format(d.value)} <span className="text-muted-foreground">{Math.round((d.value / total) * 100)}%</span>
</span>
</li>
))}
</ul>
</figcaption>
</figure>
);
}
export { DonutChart };
export type { Slice as DonutChartSlice };
For AI agents
Hand your agent this page, the markdown at /components/donut-chart/prompt.md, or the Copy AI prompt button above.
When to use
- Showing how a total splits into two to five parts
- A dashboard card where the total matters as much as the split
- With a highlight when one share is the point
When not to use
- More than five or six parts, where a sorted list or bars read better
- Comparing values across time: use the area chart
- Parts that do not add up to a meaningful whole
Agent notes
- Server component, no dependencies. Needs a width; the ring caps at 160px and centres
- Tone follows size rank, strongest foreground for the largest slice. Only the highlight uses brand
- The legend is the text alternative: label, value and percentage per slice, and the ring is aria-hidden
- Pass values, not percentages. Zero-value slices draw nothing but still list in the legend
The maths
- Each arc is value / total × C where C = 2πr and r = 40, so C ≈ 251.3 and the slices always close the ring.
- A 1.5 unit gap is cut from the end of each dash, which keeps slices apart without trimming the first or last.
- The ring is rotated -90 degrees so the first slice starts at 12 o'clock, since a circle's stroke begins at 3.