Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, ChevronRight, PieChart } from "lucide-react"

import { cn } from "@/lib/utils"
import {
  buildSunburstTree,
  walkSunburstTree,
  type ChartSunburstData,
  type SunburstTreeNode,
} from "./chart-sunburst.contract"

export interface ChartSunburstProps

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-sunburst.json

Prompt

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 --foreground as 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.

On This Page