Skip to content
cennge-ui
GitHubv0.3.1, changelog

DonutChart

A whole and its parts at a glance: a ring with the total in the middle, or a pie, beside a legend of values and shares.

CI minutes in October

By repository · sample data

  • cennge-ui1,61056.7%
  • playwright-msi-scraper39013.7%
  • payload-cache35012.3%
  • ai-niche-scout2809.9%
  • spredo-crypto-screener2107.4%

Arrow keys move between the values.

CI minutes in October by repository
RepositoryMinutesShare
cennge-ui1,61056.7%
playwright-msi-scraper39013.7%
payload-cache35012.3%
ai-niche-scout2809.9%
spredo-crypto-screener2107.4%
Minutes2,840100%
  1. The middle holds the total. Hover a part or tap it: the middle shows that part, and its legend row lights up.
  2. Tab to the ring: the arrows step from part to part, Home and End jump to the first and the last, Escape returns the middle to the total.
  3. The legend is where the values are read: each part's amount and its share of the whole.
Live: hover it, tap it, use the keys.

Field marks

Import
import { DonutChart } from "cennge-ui/charts";
Size
3.2 KB gzip for this import, its dependencies aside
Pattern
A <figure> with a focusable group over the ring and a hidden <table> of the parts
Keyboard
Arrow left Arrow right Arrow up Arrow down HomeEndEsc
SSR
Rendered on the server in the Next.js check; hydrates without a warning
Parts
Six at most read well; a part of zero or less is left out of the ring
Tests
Unit tests; end-to-end in Chromium, Firefox and WebKit, on desktop and phone viewports: donut-chart

When to use#

Use a DonutChart to show how one whole splits into a few parts, six at most: minutes by repository, runs by result. The ring gives the proportions at a glance and the legend beside it gives each part's value and share, which is where the eye reads them.

Similar#

  • BarChart compares the same values by length, one bar each: the better choice when they are many or close.
  • AreaChart with isStacked shows how a whole splits over time, not at one moment.
  • Progress shows one part of one whole, such as a task 70% done.

A ring with its total#

index names each part and value holds its amount. The middle shows the total over labels.total; a hovered, tapped or focused part takes its place. The numbers on this page are sample data.

  • bug317.6%
  • enhancement847.1%
  • documentation423.5%
  • question211.8%

Arrow keys move between the values.

Open issues in cennge-ui by label
LabelOpen issuesShare
bug317.6%
enhancement847.1%
documentation423.5%
question211.8%
Open issues17100%
basic.tsx
import { DonutChart } from "cennge-ui/charts";

const labels = [
  { label: "bug", issues: 3 },
  { label: "enhancement", issues: 8 },
  { label: "documentation", issues: 4 },
  { label: "question", issues: 2 },
];

export default function OpenIssuesByLabel() {
  return (
    <DonutChart
      aria-label="Open issues in cennge-ui by label"
      data={labels}
      index="label"
      value="issues"
      indexLabel="Label"
      valueLabel="Open issues"
      labels={{ total: "Open issues" }}
    />
  );
}

A pie#

variant="pie" fills the middle, so there is no total in it: the picked part's legend row lights up and the other parts fade. Here theme colours carry a meaning: passed in success, failed in danger, cancelled in the grey of default.

  • Passed1,18491.9%
  • Failed634.9%
  • Cancelled413.2%

Arrow keys move between the values.

CI runs in October by result
ResultRunsShare
Passed1,18491.9%
Failed634.9%
Cancelled413.2%
Total1,288100%
pie.tsx
import { DonutChart } from "cennge-ui/charts";

const results = [
  { result: "Passed", runs: 1184 },
  { result: "Failed", runs: 63 },
  { result: "Cancelled", runs: 41 },
];

export default function RunsByResult() {
  return (
    <DonutChart
      aria-label="CI runs in October by result"
      data={results}
      index="result"
      value="runs"
      indexLabel="Result"
      valueLabel="Runs"
      variant="pie"
      colors={["success", "danger", "default"]}
    />
  );
}

The small parts as Other#

colors gives the parts their colours in order; without it they take the palette's slots. default is the grey for a part with no colour of its own, such as the rest folded into Other. valueFormat writes the amounts in the middle, the legend and the table; the shares stay percentages in the provider's locale.

  • Pages412 MB62%
  • Media139 MB20.9%
  • Search index61 MB9.2%
  • Logs33 MB5%
  • Other19 MB2.9%

Arrow keys move between the values.

Disk used by payload-cache
KindSizeShare
Pages412 MB62%
Media139 MB20.9%
Search index61 MB9.2%
Logs33 MB5%
Other19 MB2.9%
Used664 MB100%
other.tsx
import { DonutChart } from "cennge-ui/charts";

const disk = [
  { kind: "Pages", megabytes: 412 },
  { kind: "Media", megabytes: 139 },
  { kind: "Search index", megabytes: 61 },
  { kind: "Logs", megabytes: 33 },
  { kind: "Other", megabytes: 19 },
];

export default function DiskUse() {
  return (
    <DonutChart
      aria-label="Disk used by payload-cache"
      data={disk}
      index="kind"
      value="megabytes"
      indexLabel="Kind"
      valueLabel="Size"
      colors={["chart-1", "chart-2", "chart-3", "chart-4", "default"]}
      valueFormat={(megabytes) => `${megabytes} MB`}
      labels={{ total: "Used" }}
    />
  );
}

Nothing to divide#

A part of zero or less is left out of the ring, and the arrow keys skip it, though the legend still lists it. When no part is left, emptyContent sits in an empty grey ring, or labels.empty ("No data") without it, the legend is gone and nothing takes focus. The table screen readers get still lists every part, at zero.

No failed runs this week

Arrow keys move between the values.

Failed CI runs this week by repository
RepositoryFailed runsShare
cennge-ui00%
payload-cache00%
ai-niche-scout00%
Total00%
empty.tsx
import { DonutChart } from "cennge-ui/charts";

const failed = [
  { repository: "cennge-ui", runs: 0 },
  { repository: "payload-cache", runs: 0 },
  { repository: "ai-niche-scout", runs: 0 },
];

export default function FailedRuns() {
  return (
    <DonutChart
      aria-label="Failed CI runs this week by repository"
      data={failed}
      index="repository"
      value="runs"
      indexLabel="Repository"
      valueLabel="Failed runs"
      emptyContent="No failed runs this week"
    />
  );
}

Accessibility#

  • The figure and the ring are named by aria-label; the ring is a focusable group described by labels.keyboardHint.
  • Focus from the keyboard picks the first part at once, and a polite live region reads each part the arrows reach, as "enhancement: 8 (47.1%)".
  • The picked part's legend row lights up; the rows of the others keep their full contrast.
  • Screen readers get a table of every part with its value and share, and a last row with the total.

Server rendering#

Nothing in a DonutChart waits for a measure: the ring, the total, the legend and the table are all rendered on the server. The ring pops in as it appears; with reduced motion it is there at once.

Props#

PropTypeDefault
datarequired
readonly T[]One item per part. Parts are positive: a zero or negative one is left out of the ring.
–
indexrequired
Key<T>The field that names each part.
–
valuerequired
Key<T>The field with each part's value.
–
aria-labelrequired
stringNames the chart for screen readers, and the table that gives them its data.
–
indexLabel
stringHeads the table's column of names.
index
valueLabel
stringHeads the table's column of values.
value
colors
readonly (ChartColor | undefined)[]Each part's colour, in order.
the palette's slots
valueFormat
(value: number) => string
numbers in the provider's locale
variant
"donut""pie"A ring with the total in the middle, or a whole pie.
"donut"
showLegend
boolean
true
labels
Partial<DonutChartLabels>
–
emptyContent
React.ReactNodeShown when there is nothing to divide.
labels.empty
className
string
–