Charts

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.

Preview in your theme

Loading preview…

"use client"

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

import { cn } from "@/lib/utils"
import {
  resolveRadialDomain,
  type ChartRadialBarData,
  type ChartRadialBarItem,
  type RadialBarDomain,
} from "./chart-radial-bar.contract"

export interface ChartRadialBarProps

Installation

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

Prompt

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 with aria-pressed, Escape clears it, and a pin whose category left the data is simply not active any more.

On This Page