Radial Chart
Token-driven radial bar chart built on Recharts v3 — concentric rings for comparing categories, or a clamped single-value gauge with a centre readout, in both light and dark themes.
Playground
Installation
pnpm add @tessinaui/ui rechartsUsage
import { RadialChart } from "@tessinaui/ui";
import type { ChartConfig } from "@tessinaui/ui";
const config: ChartConfig = {
chrome: { label: "Chrome", color: "var(--chart-1)" },
safari: { label: "Safari", color: "var(--chart-2)" },
firefox: { label: "Firefox", color: "var(--chart-3)" },
};
const data = [
{ browser: "chrome", visitors: 275 },
{ browser: "safari", visitors: 200 },
{ browser: "firefox", visitors: 187 },
];
<RadialChart
data={data}
config={config}
dataKey="visitors"
nameKey="browser"
label="Chrome leads browser share"
/>The component reads one row per ring. To draw a gauge instead, pass a single
row plus maxValue — the value then paints a proportion of the sweep rather
than filling it:
<RadialChart
data={[{ metric: "storage", used: 68 }]}
config={{ storage: { label: "Storage", color: "var(--chart-1)" } }}
dataKey="used"
nameKey="metric"
maxValue={100}
centerValue="68%"
centerLabel="of 500 GB"
formatValue={(v) => `${v}% of 500 GB used`}
label="Storage is 68% full"
/>
maxValueis not optional for gauges. Without it a single-row chart paints the entire sweep whatever the number says, because there is no scale to measure it against. Anything reading "x out of y" needs it.
Examples
Default
One ring per category in brand tones, over the default background track.
Gauge
A single value clamped to maxValue, with the readout in the middle. The open
arc leaves room for the number to breathe.
Half gauge
Sweep 180 → 0 and turn on showMinMax to print the ends of the scale, the
way a score or rating meter reads.
Tick scale
showTicks draws tickCount labels evenly around the sweep, for gauges whose
intermediate values matter and not just the endpoints.
Thresholds
thresholds recolours the arc as the value crosses each band. Colour is never
the only signal — every gauge below still prints its own number and caption.
Half circle
Set startAngle and endAngle (e.g. 180 → 0) to sweep a half circle.
Stacked total
Two series sharing one band, with the total in the middle. Stacking is a
Recharts-level composition (stackId), so this one drops to the primitives.
Stepped ring
Progress counted in discrete steps rather than measured continuously. This is
the one radial composition not built from RadialBar: a radial stack of
equal-weight segments collapses to a single sector, so the ring is drawn with the
pie primitive, which has a real angular gap.
Polar grid
A circular PolarGrid behind the rings gives the eye a scale to read arc
lengths against.
Gradient arc
Both stops are tones of one series token, so the sweep stays inside the palette and both ends hold their contrast.
End marker and dashed track
A knob at the arc end, drawn from the sector's own geometry through the shape
render prop, over a dashed track. The track is a one-radius PolarGrid ring
rather than a restyled background, which cannot carry a dash pattern.
Stat card
The dashboard shape: a small ring for the proportion, real typography beside it for the numbers that need to be read.
Mini rings
At xs the ring becomes a glyph you scan rather than a chart you read. Each
still carries its own accessible name.
Right to left
The ring keeps turning clockwise — a circular progress reading follows the clock, which is the same in every locale. What mirrors is the text, the legend's flow and marker side, and the tooltip.
In-bar labels
Enable showLabels to render category names inside each ring.
Rounded
The arc end-cap rounding follows rounded proportionally, from square none
through to pill-shaped full.
Sizes
Four square size caps via size — xs, sm, md and lg.
States
Loading skeleton, empty state, and an error that reads as an error rather than as "no data".
Theming
Series colours come from the six chart tokens (--chart-1 … --chart-6),
referenced bare as var(--chart-N) in config. Each has its own light and dark
value, so a theme switch recolours every ring with no re-render.
For a single-hue chart — the house default for one logical series split into parts — derive tones from one token instead of reaching for a second hue:
const TONES = [
"oklch(from var(--chart-1) calc(l + 0.20) c h)",
"oklch(from var(--chart-1) calc(l + 0.10) c h)",
"var(--chart-1)",
"oklch(from var(--chart-1) calc(l - 0.08) c h)",
"oklch(from var(--chart-1) calc(l - 0.16) c h)",
];Everything that is not a series — the background track, gridlines, tick labels —
is painted from the neutral tokens by the chart container, at an opacity that
stays visible on both the light and the dark chart surface. Do not restyle the
track with an inline fill: the container's rule is a CSS declaration and beats
a presentation attribute, so an inline value is silently ignored. Draw your own
background shape (see the end-marker example) when you need a different track.
intent and thresholds paint from the semantic status tokens instead of the
series palette. Use them where the value has a meaning — a health score, a
capacity warning — not to add variety.
Accessibility
- A single value with
maxValuerenders asrole="meter"witharia-valuenow/aria-valuemin/aria-valuemax. That is the right role for a measurement inside a known range; a progress bar means task completion. Multi-series charts stay a figure — a meter reports exactly one value. formatValuealso writesaria-valuetext. Screen readers announce valuetext instead of the raw number, so give it the unit and the context ("68% of 500 GB used"), not a bare digit.labelnames the chart;descriptioncarries the detail. State the insight rather than the geometry — "Storage is 68% full", not "Radial chart of storage". A static read must not require hovering anything.- The plot is a keyboard surface. Recharts' accessibility layer makes the chart focusable and steps the tooltip with the arrow keys; the focus ring is drawn by the chart container.
showLabelsputs 11px text on top of a filled arc, so the fill decides whether it is readable. The in-bar ink flips between themes — near-white on light, near-dark on dark — while a tonal ramp runs the same way in both. The two ends of a wide ramp therefore cannot both clear AA: measured on this chart, a five-stop ramp came out at 3.54:1 for the lightest tone in the light theme and 3.58:1 for the darkest tone in the dark theme, against a 4.5:1 floor. Keep a labelled chart to the middle of the ramp (the three stops in the example measure 4.98–7.10 light and 4.99–6.80 dark), or leave labels off and let the legend carry the names.- Never let colour be the only channel. Thresholds change the arc's colour; the number and its caption are what actually report the value.
- Motion respects
prefers-reduced-motion— the arc renders at its final value with no sweep. - The loading skeleton takes the shape of the chart it replaces — the same size, the same number of rings, and cut in half for a half gauge — so nothing jumps when the data lands.
Right to left
Pass dir="rtl". Text, the legend's flow and marker side, and the tooltip all
mirror.
The ring does not — the same rule Progress and Meter already ship
("RTL mirrors the linear fill; the ring never mirrors"). A circular
representation of progress is governed by the
clock, and clocks turn clockwise in every locale — Material has said so
unchanged across M1, M2 and M3 ("Circular representations of time remain the
same"), and no charting library flips a radial sweep on locale either. Note the
deliberate asymmetry with a linear progress bar, which does fill from the
right in RTL: that is the same guidance, not a contradiction, so expect
Progress and this component to differ under dir="rtl".
Endpoint labels from showMinMax stay attached to the geometry rather than
swapping sides, because the ring they annotate has not moved — a swapped label
would report the wrong end of the scale.
Let Intl build every number. Arabic-Indic digits are a property of the
region, not of "Arabic": CLDR resolves bare ar to Western digits and only
regional tags such as ar-EG and ar-SA to ٠١٢٣. Intl also emits the Arabic
percent sign (U+066A) together with the invisible bidi marks that keep it on the
correct side of the number — so never concatenate value + "%" by hand in an
RTL string, or the sign will drift.
const percent = new Intl.NumberFormat("ar-EG", { style: "percent" });
<RadialChart
dir="rtl"
centerValue={percent.format(0.68)}
formatValue={(v) => percent.format(v / 100)}
// …
/>Core props
| Prop | Type | Default | Description |
|---|---|---|---|
data | Record<string, string | number | null>[] | — | One row per ring. |
config | ChartConfig | — | Ring metadata keyed by nameKey value. |
dataKey | string | — | Datum key holding each ring's value. |
nameKey | string | — | Datum key holding the ring name (matches a config key). |
maxValue | number | — | Full-scale value. Clamps the sweep and turns a single row into a meter. |
thickness | "sm" | "md" | "lg" | number | auto | Ring band thickness. Omitted, Recharts fits the bands. |
rounded | "none" | "sm" | "md" | "lg" | "full" | "md" | Arc end-cap rounding. |
size | "xs" | "sm" | "md" | "lg" | "md" | Square size cap. |
centerValue / centerLabel | string | number | — | Centre readout and its caption. |
label / description | string | — | Accessible name and long summary. |
intent | "none" | "error" | "warning" | "success" | "info" | — | Paints the rings a status colour. |
thresholds | { from: number; intent }[] | — | Value-driven colour bands. Overrides intent. |
showMinMax | boolean | false | Print the ends of the scale. Needs maxValue. |
showTicks / tickCount | boolean / number | false / 5 | Tick scale around the sweep. Needs maxValue. |
formatValue | (value: number) => string | — | Formats ticks and the assistive value text. |
showBackground | boolean | true | Track behind each bar. |
showLabels | boolean | false | Category names inside each ring. |
startAngle / endAngle | number | 90 / -270 | Sweep angles (degrees). |
dir | "ltr" | "rtl" | "ltr" | RTL mirrors text, legend and tooltip; the ring never mirrors (spec) |
showLegend / showTooltip | boolean | true | Toggle chart chrome. |
loading | boolean | false | Render the skeleton. |
error | ReactNode | — | Render an error message instead of the chart. |
emptyState | ReactNode | "No data to display" | Shown when data is empty. |