Skip to content
goodtabs UI
BrowseBrowse components

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
Registry items by type
  • Components118 59%
  • Blocks46 23%
  • Hooks22 11%
  • Utilities14 7%

Slices in foreground tones ranked by size, total in the middle.

One slice highlighted

12,200Visits
Visits by source
  • 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

PropTypeDefaultDescription
data{ label: string; value: number }[]The parts of the whole. Up to five read well.
titlestringWhat the chart shows, for screen readers.
totalLabelstring"Total"Small label under the total.
highlightnumberIndex of the one slice drawn in the brand colour.
format(value: number) => stringStringFormats 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.