DocumentationComponentsTheme CreatorGitHub
Theme CreatorGitHubIntroduction
InstallationUsageTheming
ComponentsAccordionAction SheetAlertAlertDialogArea ChartAspectRatioAvatarBadgeBannerBar ChartBottom NavBreadcrumbButtonButtonGroupCalendarCardCarouselChartChatBubbleChatBubbleNewCheckboxChipCoachMarkCodeBlockCollapsibleColor PickerComboboxCommandContainerContextMenuDate PickerDividerDrawerDropdown MenuEmptyStateFABFieldFieldsetFile UploadFlexFormGridHoverCardIconButtonLabelLine ChartLinkMenubarMeterModalNavigation MenuNumberFieldOTP InputPaginationPickerPie ChartPopoverProgressPromptInputRadar ChartRadial ChartRadioRatingScroll AreaSearchSegmentedControlSelectShortcutSidebarSkeletonSliderCircularSliderMediaTrimmerSpacerSpinnerSplit ButtonStackStatusStepperSurfaceSwitchTableTabsTextareaTime PickerToastToggleButtonToggleGroupTokenizerToolbarTooltipTop Header DesktopTop Header Mobile
Contributing
Components

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 recharts

Usage

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"
/>

maxValue is 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 maxValue renders as role="meter" with aria-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.
  • formatValue also writes aria-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.
  • label names the chart; description carries 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.
  • showLabels puts 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

PropTypeDefaultDescription
dataRecord<string, string | number | null>[]—One row per ring.
configChartConfig—Ring metadata keyed by nameKey value.
dataKeystring—Datum key holding each ring's value.
nameKeystring—Datum key holding the ring name (matches a config key).
maxValuenumber—Full-scale value. Clamps the sweep and turns a single row into a meter.
thickness"sm" | "md" | "lg" | numberautoRing 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 / centerLabelstring | number—Centre readout and its caption.
label / descriptionstring—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.
showMinMaxbooleanfalsePrint the ends of the scale. Needs maxValue.
showTicks / tickCountboolean / numberfalse / 5Tick scale around the sweep. Needs maxValue.
formatValue(value: number) => string—Formats ticks and the assistive value text.
showBackgroundbooleantrueTrack behind each bar.
showLabelsbooleanfalseCategory names inside each ring.
startAngle / endAnglenumber90 / -270Sweep angles (degrees).
dir"ltr" | "rtl""ltr"RTL mirrors text, legend and tooltip; the ring never mirrors (spec)
showLegend / showTooltipbooleantrueToggle chart chrome.
loadingbooleanfalseRender the skeleton.
errorReactNode—Render an error message instead of the chart.
emptyStateReactNode"No data to display"Shown when data is empty.
Radar ChartRadio

On this page

PlaygroundInstallationUsageExamplesDefaultGaugeHalf gaugeTick scaleThresholdsHalf circleStacked totalStepped ringPolar gridGradient arcEnd marker and dashed trackStat cardMini ringsRight to leftIn-bar labelsRoundedSizesStatesThemingAccessibilityRight to leftCore props