DonutChart
Share of a whole, drawn on the server. At most three categories plus "Other", in fixed colours that never cycle, with a legend of values and shares beside the ring.
Import
import { DonutChart } from '@nizivo/charts/donut-chart';Static or interactive
Split. DonutChart has no 'use client'; it renders the ChartInteraction island over the ring
when its status is 'ready' and interaction is 'nizivo' (the default since M41), as AreaChart
and BarChart do. That island is its keyboard route: one Tab stop over the ring, the arrow keys over
the slices, the ChartTooltip card (see
Accessibility). interaction="none" renders no island: the ring is static, hovering
a segment or its legend row dims the others and swaps the centre in CSS alone, and the frame makes
no keyboard claim. In both modes the legend, with every value and share, and the data table carry
the same information without a pointer; tableView="details" shows the table to everyone. How to
choose.
Examples
Basic
- Online store$64,12052%
- Point of sale$34,52628%
- Wholesale$17,26314%
- Other$7,3986%
| Channel | Sales | Share |
|---|---|---|
| Online store | $64,120 | 52% |
| Point of sale | $34,526 | 28% |
| Wholesale | $17,263 | 14% |
| Other | $7,398 | 6% |
import type { JSX } from 'react';
import { DonutChart } from '@nizivo/charts/donut-chart';
interface Channel {
name: string;
sales: number;
}
// Figma 03's donut: sales by channel. Five channels fold into three plus "Other" ($7,398).
const CHANNELS: Channel[] = [
{ name: 'Online store', sales: 64120 },
{ name: 'Point of sale', sales: 34526 },
{ name: 'Wholesale', sales: 17263 },
{ name: 'Marketplace', sales: 5210 },
{ name: 'Pop-up', sales: 2188 },
];
const USD: Intl.NumberFormatOptions = {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
};
export function DonutChartExample(): JSX.Element {
return (
<DonutChart
title="Sales by channel"
summary="Share of total sales, October"
data={CHANNELS}
category={(d) => d.name}
value={(d) => d.sales}
format={USD}
labels={{ category: 'Channel', value: 'Sales', total: 'Total sales' }}
/>
);
}
Locale
Two categories: nothing folds; formatted for Germany, with a custom centre.
- Karte81.251 €80 %
- Rechnung19.800 €20 %
| Zahlungsart | Umsatz | Anteil |
|---|---|---|
| Karte | 81.251 € | 80 % |
| Rechnung | 19.800 € | 20 % |
import type { JSX } from 'react';
import { DonutChart } from '@nizivo/charts/donut-chart';
export function DonutChartLocaleExample(): JSX.Element {
return (
<DonutChart
title="Zahlungsarten"
data={[
{ name: 'Karte', sales: 81250.5 },
{ name: 'Rechnung', sales: 19800 },
]}
category={(d) => d.name}
value={(d) => d.sales}
locale="de-DE"
format={{ style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }}
center={<span className="nz-donut-chart-value">2 Arten</span>}
labels={{ category: 'Zahlungsart', value: 'Umsatz', share: 'Anteil', other: 'Andere' }}
/>
);
}
Status
Loading, empty and error keep the ring's square (§16.5).
- Online store
- Point of sale
- Wholesale
- Other
import type { JSX } from 'react';
import { DonutChart } from '@nizivo/charts/donut-chart';
interface Channel {
name: string;
sales: number;
}
// Figma 03's donut: sales by channel. Five channels fold into three plus "Other" ($7,398).
const CHANNELS: Channel[] = [
{ name: 'Online store', sales: 64120 },
{ name: 'Point of sale', sales: 34526 },
{ name: 'Wholesale', sales: 17263 },
{ name: 'Marketplace', sales: 5210 },
{ name: 'Pop-up', sales: 2188 },
];
export function DonutChartStatusExample(): JSX.Element {
return (
<div>
<DonutChart
title="Sales by channel"
status="loading"
data={[]}
category={(d: Channel) => d.name}
value={(d) => d.sales}
/>
<DonutChart
title="Sales by channel"
data={[]}
category={(d: Channel) => d.name}
value={(d) => d.sales}
/>
<DonutChart
title="Sales by channel"
status="error"
data={CHANNELS}
category={(d) => d.name}
value={(d) => d.sales}
/>
</div>
);
}
Folding
foldCategories(items, max, otherLabel, sort) is the rule DonutChart applies, exported so a server
can compute the same slices:
- Values that are not finite, or not above zero, are left out.
sort="value"(default) orders the rest largest first, ties in input order;sort="none"keeps input order.- The first
maxCategories(clamped to 1–3) takechart/cat-1,chart/cat-2andchart/cat-3, in that order. - The rest add up into one slice in
chart/neutralnamedlabels.other. When exactly one category is left over, that slice keeps its own name.
Props
Server component. Props: DonutChartProps<T>. Source: src/donut-chart/donut-chart.tsx.
| Name | Type | Default | Description |
|---|---|---|---|
categoryrequired | Accessor<T, string> | – | The category's name: a key of the datum, or a |
center | ReactNode | – | Replaces the centre (the total and its label); a hovered segment still shows its share. |
datarequired | readonly T[] | – | One datum per category, in any order ( |
empty | ReactNode | – | Replaces the default empty message. From |
error | ReactNode | – | Replaces the default error message, for example an InlineError with a retry action. From |
format | Intl.NumberFormatOptions | Intl.NumberFormat's own ({}) | Values in the legend and the table. |
interaction | ChartInteractionMode"nizivo""none" | 'nizivo' | From |
labels | Partial<DonutChartLabels> | – | |
locale | string | 'en-US' | BCP 47 locale every number is formatted in, once, on the server (§16.4). |
maxCategories | number | 3 | Categories kept before the rest fold into "Other", 1–3 (Figma: three at most). |
pending | boolean | – | A refresh is in flight while the chart shows data (§16.5): From |
render | RenderProp<ChartFrameState> | – | From |
shareFormat | Intl.NumberFormatOptions | { style: 'percent', maximumFractionDigits: 0 } | Shares. |
sort | DonutChartSort"value""none" | 'value' (largest first) | |
status | DataStatus"ready""loading""empty""error" | 'ready'; a ready chart with no table rows renders as `'empty'`. | From |
summary | ReactNode | – | One line under the title, for example "Daily sales, Oct 1–30, vs previous period". From |
tableView | ChartTableView"hidden""details" | 'hidden' | From |
titlerequired | ReactNode | – | The figure's caption and its accessible name. From |
titleVisibility | ChartTitleVisibility"visible""hidden" | 'visible' |
From |
totalFormat | Intl.NumberFormatOptions | `format` in compact notation with at most one fraction digit | The total in the centre. |
valuerequired | Accessor<T, number | null | undefined> | – | Its value; one that is not a finite number above zero is left out. |
- Also takes every
<figure>attribute exceptchildren,title.
render takes an element, or, in client code, a function of the props and this state (ChartFrameState):
| Name | Type | Default | Description |
|---|---|---|---|
interactionrequired | ChartInteractionMode"nizivo""none" | – | As passed: whether the child claims keyboard access to its points. |
pendingrequired | boolean | – | |
statusrequired | DataStatus"ready""loading""empty""error" | – | The status as rendered: |
category and value are an Accessor: a key of the datum whose values have the right type
(category="name", which a Server Component can pass), or a (datum, index) => value function, as
in every chart. format defaults to Intl.NumberFormat's own options ({}): a fractional value
shows its fraction. totalFormat defaults to format in compact notation with at most one
fraction digit, and shareFormat to { style: 'percent', maximumFractionDigits: 0 }. All three are
plain Intl.NumberFormatOptions, as @nizivo/react's Delta and Meter take them.
labels takes Partial<DonutChartLabels>, which extends ChartFrameLabels (its members are under
Other exports). The English defaults, in donutChartLabels, on top of
ChartFrame's:
| Key | Default |
|---|---|
category | Category |
value | Value |
share | Share |
other | Other |
total | Total |
ofTotal | of total |
interaction | interactive chart |
hint | Arrow keys move between slices; Home and End go to the first and last |
points | Chart slices |
Other exports
DonutChartLabelstypeimport type { DonutChartLabels } from '@nizivo/charts/donut-chart'DonutChart's strings (§16.4), with ChartFrame's. Never concatenated: each is a whole label.
Name Type Default Description categoryrequiredstring– The data table's first column header.
hintrequiredstring– The interactive layer's keyboard instructions.
interactionrequiredstring– The interactive layer's role, as assistive technology names it.
ofTotalrequiredstring– After a slice's share in the card and in what the island speaks, for example "52% of total".
otherrequiredstring– The folded remainder.
pointsrequiredstring– The interactive layer's name when
titleis not a string.sharerequiredstring– The data table's share column.
totalrequiredstring– Under the total in the centre.
valuerequiredstring– The data table's value column.
DonutChartPropstypeimport type { DonutChartProps } from '@nizivo/charts/donut-chart'DonutChart's props: its data and formats in the charts' one shape (
_core/api.ts) and ChartFrame's, the<figure>'s attributes included. It draws no axes, its legend is its own, the ring is never mirrored, andinteraction(default'nizivo') is the island's keyboard and pointer mode over the slices or, with'none', a static ring with CSS-only hover and no keyboard claim.The props of
DonutChart, listed above.DonutChartSorttypeimport type { DonutChartSort } from '@nizivo/charts/donut-chart'type DonutChartSort = "value" | "none"DonutSlicetypeimport type { DonutSlice } from '@nizivo/charts/donut-chart'One slice after folding.
Name Type Default Description labelrequiredstring– seriesrequiredExclude<ChartSeries, "current" | "previous">– valuerequirednumber– donutChartLabelsconstantimport { donutChartLabels } from '@nizivo/charts/donut-chart'English defaults (§16.4); pass
labelsto translate per instance.const donutChartLabels: DonutChartLabelsfoldCategoriesfunctionimport { foldCategories } from '@nizivo/charts/donut-chart'At most
max(1–3) categories plus one "Other" slice (otherLabel), in a stable order: values that are not finite or not above zero are dropped;sort="value"orders the rest largest first, ties in input order; the firstmaxkeep chart/cat-1… and the remainder is summed into "Other", which keeps its own label when it is one category.function foldCategories(items: ReadonlyArray<{ label: string; value: number; }>, max: number, otherLabel: string, sort?: DonutChartSort): DonutSlice[]
Data attributes and slots
Inside the ChartFrame root, which carries
nz-donut-chart (with axes="none", DonutChart's interaction and a vertical
Legend in every status, its values and shares only when ready):
| Element | Attributes |
|---|---|
svg.nz-donut-chart-svg | viewBox="-100 -100 200 200", aria-hidden |
path.nz-donut-chart-segment | data-slot="donut-chart-segment", data-series="cat-1"…"cat-3" or "other"; data-highlighted on the island's active slice. One per slice, in data order: a slice thinner than the gap gets an empty d |
div.nz-donut-chart-center | The total, read by screen readers; one more per slice with data-series and aria-hidden, shown on hover and for the island's active slice |
CSS hooks
| Hook | Default | Read as |
|---|---|---|
--nz-donut-chart-size | 12.5rem |
|
Layers: nz.components. Classes: .nz-chart-frame-body, .nz-chart-frame-caption, .nz-chart-frame-legend, .nz-chart-frame-skeleton, .nz-chart-interaction, .nz-chart-interaction-point, .nz-chart-interaction-tip, .nz-chart-visually-hidden, .nz-donut-chart, .nz-donut-chart-center, .nz-donut-chart-segment, .nz-donut-chart-svg, .nz-donut-chart-value, .nz-legend, .nz-legend-item, .nz-legend-mark.
Tokens this stylesheet reads (16)
--nz-chart-cat-1, --nz-chart-cat-2, --nz-chart-cat-3, --nz-chart-neutral, --nz-duration-chart-draw, --nz-duration-fast, --nz-easing-standard, --nz-font-body-m, --nz-font-body-s, --nz-font-numeric-l, --nz-font-numeric-m, --nz-radius-full, --nz-space-3, --nz-space-8, --nz-text-primary, --nz-text-secondary
--nz-donut-chart-size is the ring's size (200 px). Beside the legend the ring is at most 40 % of
the chart's width.
Narrow widths
The chart lays itself out by its own width (ChartFrame is a size container), never the viewport's, so it works the same in a sidebar, a card or on a phone, and on the server, which knows neither:
- Under 24rem, the legend moves under the ring and takes the full width: each row's name wraps in one flexible column, and its value and share never break. The ring takes up to the chart's whole width, centred. There is no room there for the island's card that would neither pass the chart's edge nor cover the ring, so it is not shown: the active slice's share and name fill the centre, its legend row (the others dimmed) shows its value, and the island still speaks it.
- A legend name wraps, a long word too, rather than running into its value, at every width.
- The centre steps down with the ring: Numeric/M over Body/S on a ring under 10rem. On a ring under 6rem the total no longer fits: it is clipped out of sight but stays in the markup, so a screen reader still reads it (it is in no legend row or table cell).
Accessibility
- The ring is
aria-hidden. The legend lists every slice with its value and share, and the data table has one row per slice: both are server HTML. - The centre's total is readable; the per-slice centres shown on hover are
aria-hidden. - The legend renders in every status so the box does not move, but shows values and shares only when the chart is ready: an error never shows numbers beside "could not be loaded".
- With
interaction="nizivo"(default), the island's item mode is the keyboard route (M41). The ring is one Tab stop (role="application", named bytitlewhen it is a string, elselabels.points; role descriptionlabels.interaction; described bylabels.hint, visually hidden). Keyboard focus shows the card for the active slice: the first slice, or the last one a key or the pointer showed. ArrowRight and ArrowLeft move to the next and previous slice in data order, which is clockwise; the ring is never mirrored, sodir="rtl"does not flip the keys. Home and End go to the first and last slice. The active segment getsdata-highlighted, which dims the other segments to opacity 0.35 and the other legend rows to 0.45 and swaps the centre to its share and name, as hover does. - The ChartTooltip card sits at the slice's centre, half-way through
the band, and opens toward the legend: the slice's name as its title, one row (
labels.value, a swatch in the slice's colour, the formatted value) and its share withlabels.ofTotal("52% of total"). Each keyboard move speaks the same through the polite region, joined with the list separators of the chart'slocale; withlabels={{ value: 'Sales' }}, "Online store, Sales, $64,120, 52%, of total" (donut-chart-interaction.spec.tsxinpackages/charts/test/browser). A pointer shows the card for the segment under it and never speaks; over a gap or the hole the card stays as it is. - Escape hides the card wherever focus is, as in the other island modes. On the focused ring it is
consumed and stopped, so an enclosing dialog stays open, a native
<dialog>or Dialog; with focus elsewhere it hides a card the pointer opened and still reaches an enclosing dialog (M41). - With
interaction="none", hover is CSS only, with the same dimming, and the frame makes no keyboard claim. The hover state has no Escape; it adds no information the legend does not show. PasstableView="details"where sighted keyboard users need the data table. - Forced colours: the four slices take
Highlight,Canvas(hollow),CanvasTextandGrayText, each outlined inCanvasText(the active slice 3 px), and the legend marks inside the donut match them. Only Chromium's forced-colours emulation has been looked at. - The ring fades in over
--nz-duration-chart-draw, 0 ms under Reduced motion.
Keyboard
With interaction="nizivo" (default):
| Keys | Action |
|---|---|
| Tab | Focuses the ring. A keyboard focus shows and speaks the active slice, the first one at first. |
| ArrowRight / ArrowLeft | The next or previous slice in data order (clockwise), whatever the page's direction: the ring is never mirrored. |
| Home / End | The first or last slice. |
| Escape | Hides the card and clears the highlight, wherever focus is. |
Source: chart-interaction.client.tsx in packages/charts/src/_island (item mode).
donut-chart-interaction.spec.tsx in packages/charts/test/browser checks it. With
interaction="none" the ring has no Tab stop and no keys.
Figma
Donut Chart, 7275:13455: State=Default 7275:13456 and State=Hover 7275:13486, 482 × 200
with the legend. "Share-of-whole chart, max 3 categories + Other. Hover dims non-focused segments to
35%." The ring is 200 px with a 26 px band (outer radius 100, inner 74) and real 3-unit gaps between
segments. Notes: docs/figma/03-data-viz.md §4.6.
BarChart
Counts per period, drawn on the server. Bars cap at 22 px, round only at the data end and sit under a value axis rounded out to whole ticks; one keyboard stop steps through the columns and shows each bar's tooltip card.
FunnelChart
Steps in order, as bars of each step's share of the first, with the counts and the share of the step before as text. Drawn on the server, with no client JavaScript.