Radial Bar Chart
Concentric arc bars — one ring per category on one shared ceiling, with a per-ring target tick, capped overflow, anticlockwise negatives and an sr-only value table.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-radial-bar.jsonPrompt
Build a React + TypeScript + Tailwind "ChartRadialBar" card: concentric arc
bars, one ring per category, every ring measured against ONE shared ceiling,
with the empty track left visible behind each arc. Plain SVG plus zod and
lucide-react — no chart library. recharts' RadialBar cannot do this honestly:
it re-fits its angular axis around out-of-domain data, so one over-target
category silently redefines a full lap and shrinks every sibling ring with it.
Contract
- One zod schema is the source of truth; props are z.infer of it plus the
presentation levers. Never hand-write a parallel interface.
{ status: "loading" | "empty" | "error" | "ready";
title: string; description?: string; unit?: string;
max?: number > 0; // shared ceiling, omitted = fitted
items: { id: string; label: string; value: number; target?: number }[]
.max(12) }
- No per-item unit: a shared ceiling only means something when every category
counts the same thing.
- The schema refuses what cannot be drawn honestly: ready with zero items
(that is what status "empty" is for), duplicate ids (the id is the React key
and the legend's identity), more than 12 rings (past a dozen the bands are
thinner than the gaps between them — aggregate the tail into one "Other"
ring). zod already rejects NaN and Infinity.
- Ship one pure function beside the schema, resolveRadialDomain(items, max) ->
{ min, max, span, fixed, flat }:
* min = min(0, smallest number in the data). Zero is ALWAYS on the scale.
A bar chart with a non-zero baseline exaggerates every difference on it,
and a radial one hides that even better.
* max = the caller's ceiling when given, else the largest number.
* Targets take part in a FITTED ceiling — a goal nobody reached still has
to land on the track — but never in a FIXED one: the caller asked for
that number, so a target past it is reported as off-track instead of
quietly stretching the axis out from under every other ring.
* span = max - min, forced to 1 when it would be 0, so an all-zero or
single-flat feed cannot divide by zero.
- Props: sweep (default 270, clamped 90-360), startAngle (default 0, degrees
clockwise from 12 o'clock), order ("value-desc" default | "value-asc" |
"given"), locale ("en-US"), decimals (default 2, clamped 0-6, a MAXIMUM so
whole numbers stay whole), skeletonRings (4, clamped 1-8), onSelect(item),
onRetry, emptyState, className, and the rest of the native div props spread
on the root. forwardRef to the root div.
Behavior
- ANGLE IS THE ENCODING. angleOf(v) = startAngle + (clamp(v, min, max) - min) /
span * sweep, and every arc runs from angleOf(0) to angleOf(value). With
all-positive data angleOf(0) is the start of the track, so this degenerates
into the obvious thing; with signed data the zero baseline lands inside the
track and negative arcs sweep BACK from it, exactly like bars below an axis.
- RADIUS IS NOT AN ENCODING, and that is the failure mode of this chart type:
arc length = radius x angle, so an outer ring paints more ink than an inner
ring carrying the same number (at 6 rings the outermost is about 2.4x the
innermost). Never let radius mean anything. Mitigate: share one ceiling so
the fraction of the track is comparable, keep the track visible behind every
arc as a same-radius reference, print the number on every legend row, and
order the rings by value so the ranking is also positional.
- OVERFLOW: value > ceiling (only reachable under a fixed ceiling) is capped at
the track end and drawn with two notches cut near the tip, so "truncated" is
a shape, not a colour or a tooltip. The legend, the readout, the row's
accessible name and the table all quote the true number.
- TARGETS: a tick drawn radially across that ring's band at angleOf(target),
a --card halo under a --foreground line so it reads over the arc and over the
bare track alike. A target past a fixed ceiling is clamped onto the end and
switched to a dashed tick plus an explicit "off track" note.
- ORDER: default largest-outermost, with an index tiebreak so two equal
categories never swap rings between renders. "given" keeps contract order for
feeds where the sequence itself carries meaning (severity, price tier).
- INTERACTION: one active ring, derived as hovered ?? pinned. Pointing at a
ring's slot or focusing its legend row sets hovered; clicking or pressing
Enter/Space on a row toggles a pin that survives the pointer leaving and is
reported with aria-pressed; Escape clears the pin (and only calls
preventDefault when it actually cleared one). The active ring keeps full
opacity while the others drop to 40%, its band outline switches from --border
to --foreground, and one readout line under the legend prints the whole
sentence. A pin whose id has left the data is simply not active — derive it,
do not keep a stale id alive in an effect.
- The hit target is the ring's whole SLOT (a transparent stroke exactly one
slot wide), not the painted band: a 4px band is not a pointer target, and one
slot wide means two rings can never both claim the same point.
- FOUR STATES are branches of one bg-card panel, not an afterthought:
loading = concentric muted circles plus placeholder legend rows, aria-hidden,
with one sr-only role="status" line; empty = zero-state copy (overridable);
error = message plus a "Try again" button ONLY when onRetry is passed;
ready = the plot. status "ready" with zero rows falls through to the empty
copy, because a consumer that skipped the parse must not get a blank card.
- CLEANUP: the arcs mount at zero length and grow once, driven by a single
requestAnimationFrame scheduled when the chart becomes ready; cancel it in
the effect's teardown and re-arm it whenever readiness flips. No timers, no
listeners, no observers, nothing left running.
Rendering & styling
- Geometry lives in three small pure functions so it can be reasoned about and
re-used: polar(radius, angleDeg) mapping degrees clockwise from 12 o'clock,
arcPath(radius, from, to), and ringSlot(index, count).
- arcPath MUST split the sweep into segments of at most 180 degrees. One SVG
"A" command whose two endpoints coincide draws NOTHING, so a category sitting
exactly at the ceiling of a 360-degree sweep would silently vanish — the one
case this chart is most often asked to draw. Sweep flag 1 for increasing
angles, 0 for decreasing, which is what makes negative arcs run backwards.
- ringSlot: slot = (outerRadius - innerFloor) / count, radius = outerRadius -
slot * (index + 0.5), band = clamp(slot * 0.7, 4, 18). The leftover 30% is
the gap that stops two rings reading as one thick one; the clamp is what
keeps one ring from becoming a doughnut and twelve rings from disappearing.
With a 200x200 viewBox, an outer radius of 90 and an inner floor of 28, the
outermost band plus its target-tick overhang still lands inside the box.
- Layout is viewBox + preserveAspectRatio inside an aspect-square, max-width
wrapper, so the chart scales with the card and never collapses to zero height
in a flex parent. No ResizeObserver, no measurement, no layout effect.
- COLOUR: series fills are var(--chart-1..5) cycling by painted index — one
formula shared by the arc, the legend swatch and the sr-only table. Colour is
never the only channel: identity also comes from the ring's radius, from an
ordinal printed in the track gap that matches the legend row, and from the
legend order. Draw the band's outline first in --border (widened by ~1.6
units under the band) because --muted is about 1.09:1 against a white card
and the track would otherwise have no edge at all in light mode.
- Sign gets its own channel too: below-zero arcs are overdrawn with a dashed
stroke in --card, punching regular slots out of the fill, which survives
greyscale and colour blindness. The zero ray itself is a dashed
--muted-foreground radius with a small "0" printed inside the hole.
- Motion: reveal each arc with stroke-dashoffset over pathLength={100}, which
normalises the dash to the arc's own length so nothing has to be measured and
getTotalLength() is never called. Stagger by index, capped, and put
motion-reduce:transition-none on every transition — with motion off the arcs
simply appear at full length and every feature still works.
- ACCESSIBILITY, non-negotiable:
* The plot is a role="img" svg whose aria-label is a real finding, not a
label: category count, the shared range, the longest and shortest arcs
with their share, how many rings reached their target, how many are below
zero, how many were capped. role="img" makes the children presentational,
which is safe here precisely because nothing inside the plot is
focusable.
* The legend is the keyboard surface: one real button per category, Tab to
move, Enter/Space to pin, Escape to unpin, aria-pressed for the pin, and
an aria-label carrying the entire sentence (value, share, target, delta,
capped, below zero). The visible row contents are aria-hidden so nothing
is announced twice.
* A visually-hidden table repeats every ring: ordinal, category, value,
share of ceiling, target, difference and a note column. Put sr-only on
the WRAPPER DIV, never on the table — 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.
* The readout line is aria-hidden on purpose: the focused row already
announces the same sentence and a live region would say all of it twice.
It doubles as the scale caption while nothing is active.
- Numbers: one Intl.NumberFormat with an explicit locale (Intl.*(undefined)
desyncs SSR from the visitor), maximumFractionDigits so integers stay
integers, tabular-nums everywhere, and shares under 1% printed as "<1%"
rather than rounded to "0%" while the arc still shows a sliver.
- Degenerate data is the acceptance test, not an edge case:
zero rows -> empty copy; one row -> a normal ring, not a doughnut; every
value equal -> a fitted ceiling equals that value so every ring runs the full
sweep (true, and useless without the printed numbers); one category at 100%
of a 360-degree sweep -> the split-arc path above; all values zero -> span
falls back to 1 and every arc is zero-length, no NaN in any "d"; negatives ->
anticlockwise from the zero ray; a value past a fixed ceiling -> capped with
notches; a non-finite value -> "no data", never rendered as 0; an overlong
label -> truncated in the row with the full text in title, aria-label and the
table.
Customization levers
- sweep / startAngle: 270 leaves a quadrant for the ring ordinals and reads as
a gauge; 360 is denser and drops the ordinals (the code hides them under a
24-degree gap) — pair it with a fixed max so "full lap" means something.
Rotate startAngle to line the gap up with whatever sits next to the card.
- max: omit it while a card is read on its own, set it when two cards must be
comparable, or when "% of quota" is the actual question. A fixed ceiling is
the only thing that makes two of these charts comparable at a glance.
- order: "value-desc" for ranking, "given" when the sequence is the meaning.
Flip to "value-asc" if you would rather the biggest number sat on the
shortest radius and paid less ink for it.
- Density: outer radius, inner floor and the 0.7 band ratio are the three
numbers that trade band thickness against the hole; raise the inner floor and
drop a big aggregate number into the middle if the card wants a headline.
- Palette: re-point ringColor and the arc, the swatch and the table follow
together. Colour by a stable category key instead of by painted index when
the same category must keep its colour across several charts.
- Motion: raise the stagger for a showier entrance, or delete the reveal
entirely — nothing else depends on `revealed`.
- Legend: drop the secondary line for a compact card (the numbers survive in
the readout, the accessible name and the table), or wire onSelect to drill
into a category; the pin is local state and stays either way.Concepts
- Shared ceiling — every ring is measured against one number, not against itself. That is the whole difference from a stack of progress rings: here "78% of the track" is comparable between rings, and fixing the ceiling by hand is the only way two of these cards stay comparable with each other.
- Zero ray — the floor is pinned to
min(0, smallest value), so zero always sits on the scale. When the data is signed, zero lands inside the track and is drawn as a dashed radius; below-zero categories sweep backwards from it, exactly like bars under an axis, instead of being absolute-valued into a lie. - Radius is not an encoding — arc length is radius times angle, so an outer ring paints more ink than an inner one holding the same number. The angle is the only encoding; the visible track behind every arc, the printed value on every legend row and the value ordering are what keep that bias from being read as data.
- Capped arc — a value past a fixed ceiling stops at the track end with two notches cut near the tip. The truncation is a shape rather than a colour or a tooltip, so it survives greyscale and screenshots, and the real number stays in the row, the readout and the table.
- Ordinal as second channel — each ring prints its rank in the track's gap and the same number leads its legend row, so ring and row can be matched without seeing colour at all. It is also what keeps the chart legible past five categories, where the five-hue palette starts to repeat.
- Active ring — one derived id,
hovered ?? pinned, fed by both the pointer (over the ring's whole slot) and the keyboard (focus on a legend row). Pinning is a real toggle witharia-pressed, Escape clears it, and a pin whose category left the data is simply not active any more.
Polar Area Chart
A Nightingale rose where every period owns an equal angle and the radius is area-corrected, drawing measured zeros, missing readings and below-zero periods each in their own way.
Tile Grid Map
A grid cartogram where every region is an equal-size tile at a hand-supplied grid position, classed by quantile, equal interval or manual breaks — choropleth readability with no projection and no geographic data.