Sunburst Chart
A four-state zoomable sunburst in hand-rolled SVG — concentric rings whose sweeps compose exactly, click-to-zoom with a breadcrumb, keyboard-walkable slices and an sr-only breakdown table.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-sunburst.jsonPrompt
Build a React + TypeScript + Tailwind "ChartSunburst" card in hand-rolled SVG
(no charting library — recharts has no annular-sector primitive and no
composable angular hierarchy) with zod for the contract and lucide-react for
the two state icons.
Contract
- One zod schema is the source of truth, and the component's props are
z.infer of it plus the render knobs — never a parallel hand-written
interface:
{ status: "loading" | "empty" | "error" | "ready";
title: string; rootLabel?: string; unit?: string;
nodes: { id: string; label: string; parent?: string;
value?: number }[] }
- The hierarchy arrives FLAT, with a parent pointer, because that is the
shape a `GROUP BY a, b, c`, a directory walk or a chart-of-accounts export
actually produces. A nested payload would push tree-building onto the
caller, and a hand-built tree is exactly where duplicate ids and
accidental loops get in unseen.
- `value` belongs to a LEAF. A node that has children always reports the sum
of its children and its own `value` is ignored, so a parent arc can never
disagree with the arcs it is made of.
- The schema refuses, at parse time, everything that is not a tree:
duplicate ids, a parent that does not exist, a node that is its own
parent, a parent chain that loops, and a set of rows in which nothing is
top level. The loop check matters twice over — an unguarded roll-up
recurses forever on `A -> B -> A`, and that same cycle also leaves no root,
so a builder that only scans for parentless rows draws an empty circle and
reports nothing wrong.
- Ship two pure functions beside the schema: inspectSunburstTree() for the
structural pass, and buildSunburstTree(nodes, { rootLabel, order })
returning { ok: true, root, stats } or { ok: false, issue }. The built node
carries id, label, path, depth, total, shareOfParent, shareOfTotal,
height, leafCount and children; `stats` carries how many leaves were
dropped for being negative, zero or unset, and how many groups vanished
because their whole subtree summed to nothing.
- Extra props: description, maxRings (default 3, clamped 1-6), holeRatio
(default 0.34, clamped 0.15-0.7), padAngle in degrees (default 0.6,
clamped 0-4), order ("value" | "input", default "value"), locale (default
"en-US" — Intl.*(undefined) desyncs SSR from the visitor), formatValue,
onSelect, onZoom, onRetry, className, plus forwardRef and the rest of the
native div props spread on the root.
Behavior
- GEOMETRY. Angle 0 sits at twelve o'clock and grows clockwise:
point(r, a) = (cx + r*sin a, cy - r*cos a). A child's sweep is its
parent's sweep times its share of the parent, so angles compose exactly
the way values do and every ring sums to its parent by construction. The
tiling is exact: accumulate child sweeps, then SNAP the last child onto
the parent's own end angle, so summing floats can never open a seam or
overlap a neighbour. Ring k spans radius innerRadius + (k-1)*t to
innerRadius + k*t with t = (outerRadius - innerRadius) / rings, and
rings = min(maxRings, height of the node in the middle) so a shallow tree
still fills the disc.
- ARC PATH, three cases, and the first is the whole reason this is hand
written:
* a full turn — start and end points coincide, and an SVG elliptical arc
between two identical points is defined to draw NOTHING. A single
category at 100% would silently vanish. Draw it as two half turns
instead, and wind the inner circle the other way so the non-zero fill
rule punches the hole out;
* innerRadius at zero — a pie wedge through the centre;
* otherwise — outer arc (large-arc flag = sweep > pi, sweep flag 1),
radial line inward, inner arc back (sweep flag 0), close.
- NO MINIMUM ANGLE, deliberately. A Sankey can floor a ribbon's width
because ribbons are independent; a sunburst cannot, because angle taken to
widen one slice has to come out of its siblings, and a ring that no longer
sums to its parent has stopped being a part-of-whole chart. So a 0.08%
entry is drawn at its true 0.27 degrees, which is sub-pixel and effectively
invisible — and it is still a focusable element, still in the readout, and
still in the table. Say this in the footnote rather than hiding it: "N
slices are too thin to label — arrow into them, or read the table."
- PADDING is taken out of the PAINT, never out of the allocation: shrinking
the allocation would drag every descendant off its parent. Clamp the gap
to a quarter of the sweep per side so a slice always keeps at least half
of itself — unclamped, a 0.2 degree sliver minus a 0.6 degree gap has a
negative sweep and disappears completely, and the sliver is usually the
outlier worth seeing. A slice that owns the whole turn gets no gap at all;
it has no neighbour to be parted from, and cutting one slits a solid ring.
- ZOOM. Clicking a slice that has children makes it the new centre; a leaf
click fires onSelect and pins the readout instead. The zoom path is stored
as ids and RE-RESOLVED against the current tree on every render, so
swapping `nodes` can never strand the view inside a subtree that no longer
exists — the chain stops at the last id that still resolves, then backs
out of anything that has since become a leaf. A breadcrumb above the disc
walks back; every crumb except the last is a real button.
- FOCUS AFTER ZOOM. Every zoom unmounts the slice that was just activated,
so focus would fall to <body>. Move it to the first slice of the new ring,
keyed on a zoom COUNTER rather than on the arc array, so re-laying out for
any other reason never steals focus.
- DOUBLE CLICK. Guard the zoom with the click event's own `detail` counter:
the second click of a burst lands on whatever the re-render moved under
the pointer, which is never what the reader meant. Reading it straight off
the event means there is no timer to schedule and none to clean up.
- FOUR STATES are first-class branches of one bg-card panel: three pulsing
concentric rings (aria-hidden, plus one sr-only status line, no timers) for
loading; a zero-data panel for empty; an error panel that prints either
the transport message or the specific structural issue, with a Try again
button only when onRetry exists. A fifth outcome is reachable from `ready`
and needs its own sentence: parsed fine, but nothing survived with
positive area.
- DEGENERATE DATA, each handled on purpose: zero rows and one row; every
value identical (all sweeps equal, so size encodes nothing and order plus
labels have to carry it); one category at 100% (the full-turn arc above);
negative values, which cannot own a share of a turn and are DROPPED AND
COUNTED rather than folded in as their absolute value — publishing a
refund as revenue of the same size is the failure mode to avoid; zeros and
unset values, dropped the same way; a group whose whole subtree is zero,
which disappears with it, because an empty wedge is a lie; and labels
longer than their slice, which are cut to a character budget.
- CLEANUP. One effect (the post-zoom focus hand-off) and no subscriptions:
no timers, no rAF, no ResizeObserver, no window listeners — responsiveness
comes from the viewBox, not from measuring. Element references are held in
a Map written by ref callbacks, so they are removed on unmount by the same
callback that added them.
Rendering & styling
- Semantic tokens only: var(--chart-1..5) for the branches, --foreground,
--card, --muted, --muted-foreground, --ring, --destructive, --border.
Zero hex, zero rgb(), zero invented hues. cn() merges every className.
- COLOUR. Each top-level branch takes one --chart-N slot, cycling after
five, and every descendant keeps its branch's token and gets DEEPER toward
the rim: color-mix(in oklab, var(--chart-N) S%, var(--foreground)) with S
falling 100 -> 84 -> 68 -> 52 across the rings. The direction is the
opposite of the usual fade-outward, on purpose: mixing toward --card would
dissolve an outer ring into the surface, and the palette has no headroom
for it — every --chart-* token is tuned to sit just over 3:1 against the
card, so ANY mix toward the card puts the outer rings under that bar.
Mixing toward --foreground darkens the ramp under the light theme and
lightens it under the dark one, i.e. it always travels away from the
surface, so every ring clears the same bar in both themes.
- COLOUR IS NEVER THE ONLY CHANNEL: rings are physically concentric, every
slice carries a 1px --card hairline so two same-token siblings still part,
slices are labelled wherever they fit, order around the ring encodes rank,
the legend repeats the inner ring in the same order with values, and the
sr-only table carries every exact number.
- LABELS run ALONG their ring, centred on the slice. The budget is the arc
length at the slice's mid-radius minus end padding, divided by an average
glyph advance, so it shrinks with the slice rather than with the string;
under three characters, no label at all. Text whose mid-angle lands
between 90 and 270 degrees would read upside down, so spin it a further
half turn. A full ring has no meaningful middle, so pin its label to
twelve o'clock instead of six. The halo is paint-order:stroke with a
--card stroke under a --foreground fill — the SVG equivalent of a
text-shadow ring — so a label stays readable over any fill in either theme
without having to pick a text colour per slice.
- The hole carries the current centre: its label, its total, and either the
leaf count (at the root) or its share of the grand total (zoomed). All
three are cut to a character budget derived from the hole's diameter, and
the whole group is aria-hidden because the breadcrumb and the summary
already say it.
- A slice that holds levels deeper than the ring budget gets a dashed arc
just inside its outer edge — a texture cue, not a hue — and still opens on
click.
- HIGHLIGHT. Hover and focus share one indicator, drawn last so a
neighbouring slice can never bury it: two stacked strokes, --card at 4
under --foreground at 2. Suppress the UA outline on the slices; a browser
outline around an arc's bounding box is a rectangle the size of a quadrant.
Everything not on the highlighted slice's ancestor-or-descendant path fades
to 0.35 opacity, transitioned and gated behind motion-reduce.
- RESPONSIVE via viewBox plus preserveAspectRatio and an aspect-square block
svg inside a max-width wrapper, so nothing is measured and the disc cannot
collapse to zero height in a flex parent. Every constant is in viewBox
units and scales with it.
- ACCESSIBILITY CONTRACT. The svg is role="group" with an aria-label that
names the centre and states the keyboard map, and aria-describedby
pointing at an sr-only summary that states the actual finding: total,
branch count, ring count, the largest and smallest branch with their
shares, how many slices are unlabelled, how many hold hidden levels. Each
slice is a role="button" path with an aria-label reading label, value,
share of parent and level, plus "N inside, opens" when it drills. One
roving tab stop over the slices: ArrowLeft / ArrowRight walk siblings and
WRAP, because a ring is a closed loop; ArrowUp goes in to the parent,
ArrowDown out to the first child; Home / End jump to the first and last
sibling; Enter and Space activate; Backspace zooms out one level;
preventDefault only on keys actually handled, so the page keeps its own
scrolling. Below it, an sr-only WRAPPER DIV (never the table itself — CSS
width is only a lower bound for a table box, so width:1px does not hold
one back and a narrow viewport picks up hundreds of px of horizontal
scroll) holds a table of every entry with its parent, level, value, share
of group and share of the whole. The visible readout line is aria-hidden,
because the focused slice already announces all of it and a live region
would say every word twice.
Customization levers
- maxRings: 2 for a compact card where only the headline split matters, 4-6
for an explorer. It only changes how much is drawn at once; deeper levels
stay reachable by zooming and stay in the table either way.
- holeRatio: 0.15-0.25 turns it into a near-solid pie for maximum area,
0.5-0.6 into a thin dial with a big centre stat. Raise padAngle with it;
wide gaps read as deliberate on a thin ring and as noise on a fat one.
- order: "input" whenever sibling position already carries meaning —
severity, price tier, calendar months. "value" (default) makes rank
readable in greyscale, which is worth more when the labels are arbitrary.
- Palette: re-point the five --chart-* slots and slices, legend swatches and
the depth ramp all follow. Change the 16-point step to flatten or steepen
the ramp, or key the colour off the leaf's own branch instead of the
top-level one when provenance matters more than grouping.
- Dropping the long tail is a DATA decision, not a rendering one: group
everything under a threshold into one "Other" node upstream, so the circle
still sums to the total. Do not add a minimum slice angle in the renderer.
- Interaction: onZoom to sync the breadcrumb with the URL or a side panel,
onSelect to open a detail drawer for a leaf. To make the chart read-only,
drop the roving tabIndex and the click handler and keep the table — the
static picture is still complete.Concepts
- Exact angular tiling — a child's sweep is its parent's sweep times its share of the parent, and the last child of every group is snapped onto the parent's own end angle. Angles then compose the way values do, so a ring is always worth exactly the arc above it and float drift can never open a seam.
- No minimum slice angle — the rule that separates a sunburst from a flow diagram. A Sankey may floor a thin ribbon because ribbons are independent; here, angle given to one slice is angle stolen from its siblings, so a floor would break the part-of-whole guarantee. Sub-degree slices stay sub-pixel on purpose, and stay reachable by keyboard, by readout and by table.
- Full-turn arc — one category at 100% makes an arc whose start and end points coincide, which SVG defines as drawing nothing. It is painted as two half turns with the inner circle wound the other way, so the single most likely "100%" screenshot is not a blank card.
- Depth tint from one token — a branch keeps one
--chart-*slot and mixes toward--foregroundas it goes outward, not toward--card. Mixing toward the surface would dissolve the outer rings, and this palette has no contrast headroom to spare; mixing toward the ink always travels away from the surface, so the ramp survives both themes. - Zoom with a resolvable path — the drill state is a list of ids, re-resolved against the current tree on every render. New data can never strand the view on a node that has gone; the chain simply stops at the last id that still resolves and backs out of anything that has become a leaf.
- Focus hand-off — a zoom unmounts the slice the reader just activated, which would drop focus to the document body. Focus is handed to the first slice of the new ring, keyed on a zoom counter rather than on the arcs, so an unrelated re-layout never yanks it.
Slope Chart
A four-state slope chart for two points in time — labelled at both ends, with a pooling label-avoidance pass, leader lines that go back to the real endpoint, and a same-unit check that refuses to draw a slope across two dimensions.
Chord Diagram
A four-state chord diagram drawn in plain SVG — entities on a ring sized by inflow plus outflow, bezier ribbons sized by volume, symmetric pairs or directed arrowheads, a keyboard-walkable ring and an sr-only flow table.