Skip to content
Nizivo UI

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

Sales by channelShare of total sales, October
  • Online store$64,12052%
  • Point of sale$34,52628%
  • Wholesale$17,26314%
  • Other$7,3986%
$123.3KTotal sales
Arrow keys move between slices; Home and End go to the first and last
ChannelSalesShare
Online store$64,12052%
Point of sale$34,52628%
Wholesale$17,26314%
Other$7,3986%
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.

Zahlungsarten
  • Karte81.251 €80 %
  • Rechnung19.800 €20 %
2 Arten
Arrow keys move between slices; Home and End go to the first and last
ZahlungsartUmsatzAnteil
Karte81.251 €80 %
Rechnung19.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).

Sales by channel
Loading chart
Sales by channel

No data to show

Sales by channel
  • Online store
  • Point of sale
  • Wholesale
  • Other

This chart could not be loaded

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:

  1. Values that are not finite, or not above zero, are left out.
  2. sort="value" (default) orders the rest largest first, ties in input order; sort="none" keeps input order.
  3. The first maxCategories (clamped to 1–3) take chart/cat-1, chart/cat-2 and chart/cat-3, in that order.
  4. The rest add up into one slice in chart/neutral named labels.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.

NameTypeDefaultDescription
categoryrequiredAccessor<T, string>–

The category's name: a key of the datum, or a (datum, index) function.

centerReactNode–

Replaces the centre (the total and its label); a hovered segment still shows its share.

datarequiredreadonly T[]–

One datum per category, in any order (sort orders the slices).

emptyReactNode–

Replaces the default empty message.

From ChartFrameProps.

errorReactNode–

Replaces the default error message, for example an InlineError with a retry action.

From ChartFrameProps.

formatIntl.NumberFormatOptionsIntl.NumberFormat's own ({})

Values in the legend and the table.

interactionChartInteractionMode
"nizivo""none"
'nizivo'

From ChartFrameProps.

labelsPartial<DonutChartLabels>–
localestring'en-US'

BCP 47 locale every number is formatted in, once, on the server (§16.4).

maxCategoriesnumber3

Categories kept before the rest fold into "Other", 1–3 (Figma: three at most).

pendingboolean–

A refresh is in flight while the chart shows data (§16.5): aria-busy and data-pending.

From ChartFrameProps.

renderRenderProp<ChartFrameState>–

From ChartFrameAttributes.

shareFormatIntl.NumberFormatOptions{ style: 'percent', maximumFractionDigits: 0 }

Shares.

sortDonutChartSort
"value""none"
'value' (largest first)
statusDataStatus
"ready""loading""empty""error"
'ready'; a ready chart with no table rows renders as `'empty'`.

From ChartFrameProps.

summaryReactNode–

One line under the title, for example "Daily sales, Oct 1–30, vs previous period".

From ChartFrameProps.

tableViewChartTableView
"hidden""details"
'hidden'

From ChartFrameProps.

titlerequiredReactNode–

The figure's caption and its accessible name.

From ChartFrameProps.

titleVisibilityChartTitleVisibility
"visible""hidden"
'visible'

'hidden' hides the title and summary visually; they still name the figure.

From ChartFrameProps.

totalFormatIntl.NumberFormatOptions`format` in compact notation with at most one fraction digit

The total in the centre.

valuerequiredAccessor<T, number | null | undefined>–

Its value; one that is not a finite number above zero is left out.

  • Also takes every <figure> attribute except children, title.

render takes an element, or, in client code, a function of the props and this state (ChartFrameState):

NameTypeDefaultDescription
interactionrequiredChartInteractionMode
"nizivo""none"
–

As passed: whether the child claims keyboard access to its points.

pendingrequiredboolean–
statusrequiredDataStatus
"ready""loading""empty""error"
–

The status as rendered: 'empty' when a ready chart has no rows.

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:

KeyDefault
categoryCategory
valueValue
shareShare
otherOther
totalTotal
ofTotalof total
interactioninteractive chart
hintArrow keys move between slices; Home and End go to the first and last
pointsChart slices

Other exports

DonutChartLabels type

import type { DonutChartLabels } from '@nizivo/charts/donut-chart'

DonutChart's strings (§16.4), with ChartFrame's. Never concatenated: each is a whole label.

NameTypeDefaultDescription
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 title is not a string.

sharerequiredstring–

The data table's share column.

totalrequiredstring–

Under the total in the centre.

valuerequiredstring–

The data table's value column.

DonutChartProps type

import 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, and interaction (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.

DonutChartSort type

import type { DonutChartSort } from '@nizivo/charts/donut-chart'

type DonutChartSort = "value" | "none"

DonutSlice type

import type { DonutSlice } from '@nizivo/charts/donut-chart'

One slice after folding.

NameTypeDefaultDescription
labelrequiredstring–
seriesrequiredExclude<ChartSeries, "current" | "previous">–
valuerequirednumber–
donutChartLabels constant

import { donutChartLabels } from '@nizivo/charts/donut-chart'

English defaults (§16.4); pass labels to translate per instance.

const donutChartLabels: DonutChartLabels

foldCategories function

import { 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 first max keep 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):

ElementAttributes
svg.nz-donut-chart-svgviewBox="-100 -100 200 200", aria-hidden
path.nz-donut-chart-segmentdata-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-centerThe 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

HookDefaultRead as
--nz-donut-chart-size12.5rem
  • inline-size on .nz-donut-chart > .nz-chart-frame-body

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 by title when it is a string, else labels.points; role description labels.interaction; described by labels.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, so dir="rtl" does not flip the keys. Home and End go to the first and last slice. The active segment gets data-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 with labels.ofTotal ("52% of total"). Each keyboard move speaks the same through the polite region, joined with the list separators of the chart's locale; with labels={{ value: 'Sales' }}, "Online store, Sales, $64,120, 52%, of total" (donut-chart-interaction.spec.tsx in packages/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. Pass tableView="details" where sighted keyboard users need the data table.
  • Forced colours: the four slices take Highlight, Canvas (hollow), CanvasText and GrayText, each outlined in CanvasText (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):

KeysAction
TabFocuses the ring. A keyboard focus shows and speaks the active slice, the first one at first.
ArrowRight / ArrowLeftThe next or previous slice in data order (clockwise), whatever the page's direction: the ring is never mirrored.
Home / EndThe first or last slice.
EscapeHides 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.

On this page