Radial

Radial charts bend bars into rings and arcs, to show progress toward a goal or compare a few values at a glance.

LLMs

Code

Radial bar charts and gauges are drawn with TanStack Charts, through the kit's Chart components. They paint the chart with the theme's colors, grid and type, and add three palette props. The chart itself is a TanStack definition, written the way TanStack documents it.

Install

npx antuikit@latest init
npx antuikit@latest add tanstack-chart
The first command installs AntuikitProvider and the theme tokens. The second adds the chart components and TanStack Charts itself.

Your first radial bar chart

Draw the rings with radialBarAngle inside a polar container. radius names the category, one ring each, on a band scale. angle names the value, on a linear scale whose domain says what a full sweep means.
import { defineChart } from "@tanstack/charts"
import { focusGroupAngle, polar, radialBarAngle } from "@tanstack/charts/polar"
import { scaleBand } from "@tanstack/charts/scales/band"
import { scaleLinear } from "@tanstack/charts/scales/linear"
import { tooltip } from "@tanstack/charts/tooltip"
import { Chart } from "@/components/ui/tanstack-chart"
const browsers = [
{ browser: "Chrome", visitors: 275 },
{ browser: "Safari", visitors: 200 },
{ browser: "Firefox", visitors: 187 },
{ browser: "Edge", visitors: 173 },
{ browser: "Other", visitors: 90 },
]
const browserNames = browsers.map((row) => row.browser)
// The track: every ring drawn to the end of the scale.
const tracks = browsers.map((row) => ({ ...row, track: 300 }))
const definition = defineChart(
{
marks: [
polar({
scales: {
angle: { scale: scaleLinear().domain([0, 300]) },
radius: {
scale: scaleBand<string>().domain(browserNames).paddingInner(0.2),
range: [30, 110],
},
},
marks: [
radialBarAngle(tracks, {
id: "tracks",
angle: "track",
radius: "browser",
key: "browser",
fill: "var(--ant-color-fill-secondary)",
}),
radialBarAngle(browsers, {
id: "visitors",
angle: "visitors",
radius: "browser",
key: "browser",
color: "browser",
}),
],
}),
],
scales: { x: null, y: null },
color: { domain: browserNames },
margin: 0,
},
{
focus: focusGroupAngle,
tooltip: {
use: tooltip,
content: (points) => {
const point = points.find((item) => item.markId === "visitors")
return {
title: point?.datum.browser,
rows: point
? [{ label: "Visitors", value: String(point.datum.visitors), color: point.color }]
: [],
}
},
},
},
)
export function BrowsersChart() {
return (
<Chart
definition={definition}
ariaLabel="Visitors by browser, out of 300"
height={240}
/>
)
}
The chart fills its container's width, so give it a height. ariaLabel is required.

Which component

All three take the same definition and the same palette props.
Chart components

What a full sweep means

The angle scale's domain maps values onto the sweep. Set its end to the goal or the capacity, not to the largest value, so the rings measure against something the reader knows. A value past the end runs past the full sweep, so cap it first.
// A full turn is all 25 seats on the plan.
angle: { scale: scaleLinear().domain([0, 25]) }

Start and sweep

startAngle and endAngle on polar set the arc the rings share, in radians. 0 is 12 o'clock and angles grow clockwise. An end angle smaller than the start sweeps counterclockwise.
// From 3 o'clock, a full turn counterclockwise.
polar({ startAngle: Math.PI / 2, endAngle: -Math.PI * 1.5, ... })
// A half ring, from 3 o'clock over the top to 9 o'clock.
polar({ startAngle: Math.PI / 2, endAngle: -Math.PI / 2, ... })

Ring size and the hole

  • range on the radius scale sets where the rings start and end, in pixels. A start above 0 leaves a hole, so the inner ring does not shrink to a dot.
  • Pass functions to keep the rings in proportion as the chart resizes: range: [({ radius }) => radius * 0.2, ({ radius }) => radius].
  • paddingInner on the band scale sets the gap between rings.
  • cornerRadius: "full" rounds both ends to half the ring's thickness.

Value in the center

Put the number in a second polar with plain linear scales, and place radialText at angle and radius 0. A second line moves down with dy.
polar({
scales: {
angle: { scale: scaleLinear().domain([0, 1]) },
radius: { scale: scaleLinear().domain([0, 1]) },
},
marks: [
radialText([{ id: "total", text: "18" }], {
angle: 0,
radius: 0,
key: "id",
text: "text",
fill: chartColors.text,
fontSize: 36,
fontWeight: 700,
}),
radialText([{ id: "label", text: "Seats used" }], {
angle: 0,
radius: 0,
key: "id",
text: "text",
dy: 24,
fill: chartColors.label,
fontSize: 14,
}),
],
})

Segments on one ring

Give each row an angle1 and an angle2 to place its segment between two values. Segments that share a ring sit end to end.
const segments = [
{ channel: "In store", ring: "orders", start: 0, end: 570 },
{ channel: "Online", ring: "orders", start: 570, end: 1830 },
]
radialBarAngle(segments, {
angle1: "start",
angle2: "end",
radius: "ring",
key: "channel",
color: "channel",
stroke: chartColors.surface,
strokeWidth: 2,
})

Gauge

A gauge is a donut over part of a turn. Split the value and the rest with pie over a partial range, and paint the rest with a light fill.
const arcs = pie(
[
{ id: "value", value: 72 },
{ id: "remainder", value: 28 },
],
// Three quarters of a turn, with the gap at the bottom.
{ value: "value", startAngle: (-Math.PI * 3) / 4, endAngle: (Math.PI * 3) / 4 },
)
radialArc(arcs, {
key: "id",
color: "id",
innerRadius: ({ radius }) => radius * 0.72,
})
// On the definition:
color: {
domain: ["value", "remainder"],
range: [chartColors.series[0], "var(--ant-color-fill-secondary)"],
}
For a needle gauge, draw the bands with radialArc, then add a radialRule from radius 0 for the needle, a radialDot at the center for the hub, and a radialText under it for the value. Give polar the same startAngle and endAngle as the bands' pie, and map the reading with a linear angle scale, so the needle lands on the right band.

Colors

The chart takes the theme's chart colors without any setup. Change the palette on the chart itself, or paint one mark with fill and chartColors. The Color guideline covers which palette fits which data.
<Chart definition={definition} ariaLabel="..." palette="accent" />
// One ring in the accent color.
radialBarAngle(seats, { angle: "seats", radius: "ring", fill: chartColors.series[1] })
Palette props
Taken by Chart, RendererChart and CanvasChart. Rows marked antuikit in the Version column are added by the kit.

Tooltip and focus

focus: focusGroupAngle snaps focus to the nearest ring along the pointer's angle. A track drawn as a second bar mark also gets focus points, so in the tooltip's content, pick the value mark's point by its markId, as in the first example.

Animation

Use RendererChart with TanStack's motion() renderer to animate marks in and between data updates. It skips the animation when the reader has asked for reduced motion.
import { motion } from "@tanstack/charts/motion"
import { RendererChart } from "@/components/ui/tanstack-chart"
const renderer = motion({ initial: "always" })
<RendererChart definition={definition} renderer={renderer} ariaLabel="..." />

API

The options radial bars, rings and gauges use most. TanStack Charts' documentation has the full reference.
radialBarAngle
polar
Angle and radius scales
pie, for gauges
radialArc