Radial bars: one ring is a gauge, several are concentric progress rings. Full or half circle, a track, text in the middle, in a frame with a table, patterns and states.
Quarterly goal
Revenue target, $400k
"use client"
import { ChartRadial } from "@/components/ballmac/chart-radial"
const data = [{ name: "goal", label: "of the Q4 goal", value: 72 }]
export default function ChartRadialDemo() {
return <ChartRadial className="w-full max-w-sm" title="Quarterly goal" description="Revenue target, $400k" data={data} valueFormatter={(v) => `${v}%`} categoryLabel="Goal" valueLabel="done" />
}Installation
$ pnpm dlx shadcn@latest add @ballmac/chart-radialInstall the dependencies.
$ pnpm add recharts@^3Add the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/chart @ballmac/chart-frame @ballmac/i18nCopy the source into your project.
components/ballmac/chart-radial.tsx// Ballmac UI: Chart Radial. https://ui.ballmac.com/components/chart-radial "use client" import * as React from "react" import { Cell, PolarAngleAxis, RadialBar, RadialBarChart } from "recharts" import { ChartCenterLabel, ChartTooltip, ChartTooltipContent } from "@/components/ballmac/chart" import { ChartFrame, type ChartFrameProps, type ChartRow, type ChartSeries } from "@/components/ballmac/chart-frame" import { useLocale, useMessages } from "@/lib/ballmac/i18n" type ChartRadialProps = Omit<ChartFrameProps, "children" | "series" | "xKey" | "summaryMode" | "skeleton" | "table" | "data"> & { /** One row per ring: its name under `nameKey`, its number under `valueKey`, and optionally a `label` and a `color`. One row is a gauge. */ data: ChartRow[] nameKey?: string valueKey?: string /** What the full ring stands for. Defaults to 100, or the largest value when that is more. */ max?: number /** What the numbers count, for the table header: "minutes". */ valueLabel?: string /** Draw only the upper half, like a gauge. */ half?: boolean /** Text in the middle: `true` writes the first ring's share, or give `{ value, label }`. */ centerLabel?: boolean | { value: string; label: string } /** Draw the empty part of each ring. */ track?: boolean tooltip?: boolean } /** * Radial bars in a ChartFrame: one ring is a gauge, several are concentric progress rings. Full or half circle, a track behind each ring, text * in the middle, a legend that switches rings off, a table of value and share of the maximum, patterns, and loading, empty and error states. */ function ChartRadial({ data, nameKey = "name", valueKey = "value", max, valueLabel, half = false, centerLabel = true, track = true, tooltip = true, categoryLabel, categoryFormatter, valueFormatter, summary, ...frame }: ChartRadialProps) { const msg = useMessages() const locale = useLocale() const nf = React.useMemo(() => new Intl.NumberFormat(locale), [locale]) const pct = React.useMemo(() => new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 0 }), [locale]) const number = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : 0) const write = (v: number) => (valueFormatter ? valueFormatter(v, valueKey) : nf.format(v)) const top = Math.max(0, ...data.map((r) => number(r[valueKey]))) const full = max ?? (top > 100 ? top : 100) const items: ChartSeries[] = data.map((row) => { const name = String(row[nameKey]) return { key: name, label: typeof row.label === "string" ? row.label : categoryFormatter ? categoryFormatter(row[nameKey]) : name, color: typeof row.color === "string" ? row.color : undefined } }) const labelOf = (name: string) => items.find((i) => i.key === name)?.label ?? name const written = summary ?? data.map((row) => msg("chart-radial.summary", "{name}: {value} of {max} ({share}).", { name: labelOf(String(row[nameKey])), value: write(number(row[valueKey])), max: write(full), share: pct.format(number(row[valueKey]) / full) })).join(" ") return ( <ChartFrame data={data} xKey={nameKey} series={items} summary={written} aspect={half ? "wide" : "square"} skeleton="donut" legend={data.length > 1 ? "toggle" : "none"} categoryLabel={categoryLabel} valueFormatter={valueFormatter} categoryFormatter={categoryFormatter} table={{ columns: [ { key: nameKey, label: categoryLabel ?? msg("chart-radial.category", "Category"), format: (v) => labelOf(String(v)) }, { key: valueKey, label: valueLabel ? valueLabel.charAt(0).toUpperCase() + valueLabel.slice(1) : msg("chart-radial.value", "Value"), numeric: true, format: (v) => write(number(v)) }, { key: "__share", label: msg("chart-radial.share", "Of {max}", { max: write(full) }), numeric: true, format: (_v, row) => pct.format(number(row[valueKey]) / full) }, ], rows: data, }} {...frame} > {(chart) => { const shown = data.filter((row) => !chart.isHidden(String(row[nameKey]))) const first = shown[0] // Rings run the way the page reads: clockwise left to right, counterclockwise right to left. const [startAngle, endAngle] = half ? (chart.rtl ? [0, 180] : [180, 0]) : chart.rtl ? [90, 450] : [90, -270] const center = centerLabel === true ? (first ? { value: pct.format(number(first[valueKey]) / full), label: labelOf(String(first[nameKey])) } : null) : centerLabel || null return ( <RadialBarChart data={shown} cy={half ? "88%" : "50%"} innerRadius={half ? "105%" : "58%"} outerRadius={half ? "165%" : "92%"} startAngle={startAngle} endAngle={endAngle} barSize={shown.length > 1 ? undefined : half ? 36 : 26} accessibilityLayer> <defs>{chart.defs}</defs> <PolarAngleAxis type="number" domain={[0, full]} angleAxisId={0} tick={false} /> {tooltip ? <ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel nameKey={nameKey} />} /> : null} <RadialBar dataKey={valueKey} angleAxisId={0} cornerRadius={10} background={track ? { fill: "var(--muted)" } : false} isAnimationActive={chart.animate} animationDuration={700}> {shown.map((row) => { const key = String(row[nameKey]) return <Cell key={key} fill={chart.fill(key)} stroke={chart.patterns ? chart.color(key) : undefined} strokeWidth={chart.patterns ? 1.5 : undefined} /> })} </RadialBar> {center ? <ChartCenterLabel value={center.value} label={center.label} cy={half ? 0.88 : 0.5} /> : null} </RadialBarChart> ) }} </ChartFrame> ) } export { ChartRadial, type ChartRadialProps }Update the import paths to match your project setup.
Usage
import { ChartRadial } from "@/components/ballmac/chart-radial"The full example is in the Code tab above.
Examples
Concentric rings
Today's rings
Percent of each daily goal
"use client"
import { ChartRadial } from "@/components/ballmac/chart-radial"
const data = [
{ name: "move", label: "Move", value: 84 },
{ name: "exercise", label: "Exercise", value: 62 },
{ name: "stand", label: "Stand", value: 41 },
]
export default function ChartRadialRings() {
return <ChartRadial className="w-full max-w-sm" title="Today's rings" description="Percent of each daily goal" data={data} centerLabel={false} valueFormatter={(v) => `${v}%`} categoryLabel="Ring" valueLabel="done" />
}Half-circle gauge
Server load
Now
"use client"
import { ChartRadial } from "@/components/ballmac/chart-radial"
const data = [{ name: "cpu", label: "CPU load", value: 64, color: "var(--chart-4)" }]
export default function ChartRadialHalf() {
return <ChartRadial className="w-full max-w-md" title="Server load" description="Now" data={data} half valueFormatter={(v) => `${v}%`} categoryLabel="Metric" valueLabel="used" />
}Patterns for color blindness
Coverage
Switch Patterns on to tell the rings apart without color
"use client"
import { ChartRadial } from "@/components/ballmac/chart-radial"
const data = [
{ name: "docs", label: "Docs", value: 92 },
{ name: "tests", label: "Tests", value: 74 },
{ name: "types", label: "Types", value: 58 },
]
export default function ChartRadialPatterns() {
return <ChartRadial className="w-full max-w-sm" title="Coverage" description="Switch Patterns on to tell the rings apart without color" data={data} patterns="toggle" centerLabel={false} valueFormatter={(v) => `${v}%`} categoryLabel="Area" valueLabel="covered" />
}API reference
| Prop | Type | Default |
|---|---|---|
data*One row per ring: its name under `nameKey`, its number under `valueKey`, and optionally a `label` and a `color`. One row is a gauge. | ChartRow[] | — |
nameKey | string | "name" |
valueKey | string | "value" |
maxWhat the full ring stands for. Defaults to 100, or the largest value when that is more. | number | — |
valueLabelWhat the numbers count, for the table header: "minutes". | string | — |
halfDraw only the upper half, like a gauge. | boolean | false |
centerLabelText in the middle: `true` writes the first ring's share, or give `{ value, label }`. | boolean | { value: string; label: string } | true |
trackDraw the empty part of each ring. | boolean | true |
tooltip | boolean | true |
value* | string | — |
label* | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Screen readers | The summary reads every ring as 'value of max (share)' |
| Tab, Enter / Space | Legend buttons switch series off and on; the Chart / Table switch shows the numbers as a table |
| Screen readers | The figure has a name and a summary written from the data |
Quality scorecard
Every applicable check passes
Measured in Chromium 153.0.8010.12 on October 19, 2026, on version 1.0.0, across 4 examples.
Passed: Accessibility scan
No serious or critical issues in light and dark, on a phone and a desktop (axe-core 4.13.0, WCAG 2.1 AA and best-practice rules).
Passed: Small screens
No sideways scrolling at 360, 390 or 1280 px wide.
Passed: Reduced motion
No endless animation to begin with.
Not applicable: Right to left
Not mirrored by design: Recharts draws its axes in SVG coordinates, which do not mirror.
Passed: Clean console
No console errors or React hydration errors while loading.
Passed: Keyboard
15 tab stops, each showing a visible change when it takes focus. 3 documented keys.
Not applicable: Loading, empty and error
Does not show data or take submissions.
Passed: Automated tests
Covered by charts-batch-families.test.tsx.
For information: Footprint
2.2 KB of source (gzip) in 1 file. Installs recharts.
Automated checks find a subset of problems and do not replace testing with a screen reader and a keyboard in your own app. What they miss.
Use with AI
<ChartRadial title description data={[{ name, label?, value, color? }]} max={100} />. One row is a gauge, several rows are concentric rings. half for a semicircle, centerLabel (true writes the first ring's share, or { value, label }), track, valueLabel, legend, patterns, state. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Chart Radial (@ballmac/chart-radial) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Progress toward a goal
- A single metric against its maximum
- Activity or goal rings
Not for
- Parts of a whole (use chart-pie)
- Comparing many categories (use chart-bar)
Registry JSON: https://ui.ballmac.com/r/chart-radial.json
Credits
Free to use in personal and commercial projects.
- npm
- recharts@^3
Pairs well with
Chart Frame
The card around a Recharts chart: a Table view with CSV download, a legend that toggles series, patterns so series differ by more than color, a summary for screen readers, and loading, empty and error states.
Stat Card
A metric card with a clear comparison trend, optional visual, and readable value hierarchy.
Progress Ring
A token-colored circular progress meter with a readable center and accessible value.
Activity Feed
A compact actor and action feed with timestamps and an empty state.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.