Skip to content
txbrendan/ui

Donut Chart

beta

A 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

Visitors by browser
  • Chrome275
  • Safari200
  • Firefox187
  • Edge173
  • Other90

two

Seats by plan
  • Pro1,240
  • Team860

currency

Ad spend by channel
  • Search$48,200
  • Social$31,750
  • Email$12,400

size

Small
  • Pro1,240
  • Team860

Medium
  • Pro1,240
  • Team860

Large
  • Pro1,240
  • Team860

loads, then draws

Visitors by browser
  • Chrome
  • Safari
  • Firefox
  • Edge
  • Other

Loading chart

loading

Visitors by browser

Loading chart

empty

Visitors by browser
No visitors yet

No visitors yet

error

Visitors by browser

Visitors could not load.

Visitors could not load.

Install

  1. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. 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

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()}
/>