Sunburst Chart
Token-driven sunburst chart built on Recharts v3 — a part-to-whole hierarchy as concentric rings, with click and keyboard drill-down, a centre back control, a breadcrumb and a legend that follows the pointer.
pnpm add @tessinaui/uiWhen to use
- For a whole whose parts have parts. Revenue by product, then plan, then region; a budget by department, then team. Each ring splits the ring inside it.
- To find where most of the whole sits, level by level. The largest arc on each ring is the first thing the eye lands on.
- When readers need to explore. A segment opens out to the whole circle, so a deep tree can be read one branch at a time (Drill-down).
When not to use
- For a flat list of parts. One level is a PieChart.
- To compare values precisely. Arc length on a ring is hard to rank. Use a BarChart, or put the numbers in a Table.
- For values that do not add up to a whole, or that go negative. A share cannot be negative; the chart warns in development and draws nothing for it.
- For flows between stages. Money moving from one bucket to another is a flow, not a hierarchy.
- For more than about eight parts on a ring. Arcs become slivers that cannot be read, pointed at or labelled. Fold the tail into "Other" (Group the tail).
Installation
pnpm add @tessinaui/ui rechartsUsage
import { SunburstChart, type SunburstNode } from "@tessinaui/ui";
// One root; a leaf carries a value, a parent is the sum of its children.
const data: SunburstNode = {
name: "Revenue",
children: [
{
id: "cloud",
name: "Cloud",
children: [
{ id: "pro", name: "Pro", value: 496 },
{ id: "team", name: "Team", value: 227 },
],
},
{ id: "desktop", name: "Desktop", value: 199 },
],
};
<SunburstChart
data={data}
label="Cloud brings in almost four fifths of revenue"
description="Revenue of 922: Cloud 723, Desktop 199."
/>Examples
Default
The first ring is the root's children. Each ring outward is one step lighter, and every other branch starts one step darker, so two neighbours never share a tone where they meet. Point at a segment and everything off its path steps back.
Drill-down
Click a segment, or focus it and press Enter, and it opens out to the whole circle. The hub then reads "back": it, a crumb in the trail or Escape goes up a level. Under reduced motion the chart changes view without the zoom.
Controlled path
path holds the drill state: the ids from the root's first child down. Keep it
in the URL or move it from outside the chart; onPathChange reports every
drill. onNodeClick reports a click on any segment, a leaf included.
Rings
levels sets how many rings are drawn below the current view. Three is the
default and the most the tones can carry in the light theme; deeper parts appear
as the reader drills.
Branch colours
A colour in config, keyed by a node's id, tints that node's whole branch, and
each ring outward lifts it one step. Measure every colour you pass against the
page in both themes.
Labels
showLabels prints names along the radius, kept upright. A name is drawn only
where it fits its ring; the rest stay in the tooltip, the hub and the legend.
Tooltip and formatting
valueFormatter spells every number: the hub, the legend, the tooltip and each
segment's accessible name. The tooltip adds the path, the share of the parent
and the share of the total.
Legend
The legend lists the ring around the hub with each value and share. Point at a row and its branch stands out; point at a segment and its row does. A row with parts inside it is a button that opens it, the same as the segment.
Group the tail
Fold small children into one "Other" node before the data reaches the chart. One "Other" can be read and pressed; ten slivers cannot.
In a card
Corner rounding
rounded rounds every segment's corners, resolved per segment: a thin segment
takes the largest corner it can carry.
Sizes
size caps the diameter at 240, 320 or 400 pixels. Below the cap the chart
fills its container.
Accessible name and data table
On a coloured plate
Inside a coloured Surface every segment is the plate's ink: full on the first
ring, 80% on the next, alternating outward. The seams and the labels tell
neighbours apart, so turn labels on.
Right-to-left
The rings run clockwise from twelve o'clock in every direction: a circle has no
reading direction. The trail, the legend, the tooltip and the left and right
arrow keys follow dir.
Narrow container
States
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
data | SunburstNode | — | The root: { id?, name, value?, children? }. A leaf carries value; a parent is the sum of its children. |
config | ChartConfig | {} | Colours and labels keyed by node id. A colour tints the node's whole branch. |
levels | number | 3 | Rings drawn below the current view. |
path | string[] | — | The drill path, controlled: node ids from the root's first child down. |
defaultPath | string[] | [] | The drill path to start on, uncontrolled. |
onPathChange | (path: string[]) => void | — | Called on every drill in or out. |
onNodeClick | (node: SunburstChartNodeInfo) => void | — | Called when a segment or legend row is clicked, before any drill. |
valueFormatter | (value: number, node) => string | Intl.NumberFormat | Formats every number the chart prints. |
showLabels | boolean | false | Names on the arcs, where they fit. |
showBreadcrumb | boolean | true | The trail above the chart. |
showLegend | boolean | true | The list of the first ring below the chart. |
showTooltip | boolean | true | The tooltip on pointer hover. |
size | "sm" | "md" | "lg" | "md" | Caps the diameter at 240, 320 or 400 pixels. |
rounded | "none" | "sm" | "md" | "lg" | "full" | "none" | Corner rounding of each segment. |
locale | string | follows dir | Locale for numbers and shares. |
dir | "ltr" | "rtl" | inherited | Reading direction, and the language of the built-in strings. |
strings | Partial<SunburstChartStrings> | — | Overrides for the built-in strings: the trail's name, segment names, the hub, the announcement. |
label | string | — | Accessible name of the figure and the tree. State the insight. |
description | string | — | Visually hidden summary for assistive tech. |
loading | boolean | false | Render the skeleton. |
error | ReactNode | — | Render an error message instead of the chart. |
emptyState | ReactNode | "No data to display" | Shown when nothing in the tree has a value. |
A node's id is its own id (or its name), and its full id is the path of ids
below the root joined by /: "cloud/pro/eu". Give each node an id unique
among its siblings, so two regions called "EU" under different plans never
collide. path and onPathChange speak full ids.
Colour
Segments are tones of --chart-1. A ring is one step (l + 0.08) lighter than
the ring inside it, and every other branch of the first ring starts one step
darker. In the light theme three steps from the base stay at or above 3:1
against the page (5.92, 4.53 and 3.43:1); a fourth reads 2.61:1, which is why
levels defaults to three. In the dark theme a lighter step only gains
contrast (from 5.05:1 up).
Two neighbouring steps differ from each other by only about 1.3:1, so colour never separates two segments on its own: a seam in the colour behind the chart runs between every segment and every ring, and names carry the meaning in the tooltip, the hub, the legend and on the arcs.
Label ink follows the tone: the page's background ink over the darker steps,
the text ink over the lighter ones in the light theme. A colour you pass in
config keeps the same rule, so check its labels.
Accessibility
The chart is a figure named by label, and its rings are a tree: each
segment is a treeitem named "Pro, $496K, 61% of Cloud", with its level, its
position among its siblings and, for a parent, whether its children are drawn.
| Key | Action |
|---|---|
Tab | Focus the chart (one stop — the last segment focused, else the first) |
← / → | Previous / next segment around the ring, in reading order; wraps |
↓ | First part of the focused segment, on the next ring out |
↑ | The segment it sits in, on the next ring in |
Home / End | First / last segment among its siblings |
Enter / Space | Open the focused segment |
Esc | Go back up a level (does nothing at the top, so an enclosing dialog still gets it) |
After a drill, focus moves to the first part of the segment you opened, or back to the branch you came out of, and a polite announcement says what is showing ("Showing Cloud, 3 parts"). The hub and the trail are real buttons with names; the focused segment carries a ring drawn along its own outline.
A thin segment is too small to press with a finger. Its row in the legend, and the keyboard, reach the same part with a full-size target. The example's data table gives every value as text.
Boundaries
- No parent value of its own. A parent is always the sum of its children; a
different
valueon a parent is ignored, with a development warning. - No negative or zero parts. They have no arc, so they are not drawn.
- No outside labels with leader lines. Names sit on the arcs or in the legend.
- No multi-select. A click opens a segment;
onNodeClickreports it.
Theming
Segment tones come from --chart-1, or the colours you pass in config; both
themes are handled by the tokens. The seams are the colour behind the chart,
the hub and legend use the page's text tokens, and everything follows a
coloured Surface without a prop.