Radial chart
A ring that shows one ratio, with a centered label and optional segments.
Containment, 7 days
- Contained
- 71%
- Transferred
- 22%
- Abandoned
- 7%
import { RadialChart } from "@oration/canon/components/radial-chart";import { cn } from "@oration/canon/lib/utils";export function Hero() { const rows = [ { label: "Contained", value: "71%", swatch: "bg-ink-65" }, { label: "Transferred", value: "22%", swatch: "bg-muted shadow-[inset_0_0_0_1px_var(--border-strong)]", }, { label: "Abandoned", value: "7%", swatch: "bg-muted shadow-[inset_0_0_0_1px_var(--border-strong)]", }, ]; return ( <section aria-labelledby="containment-7d" className="w-full max-w-sm rounded-xl bg-card p-4 text-left shadow-border" > <h3 id="containment-7d" className="text-sm font-semibold"> Containment, 7 days </h3> <div className="mt-4 flex items-center gap-4"> <RadialChart value={0.71} size={96} thickness={9} label="71%" sublabel="contained" ariaLabel="71% of supplier calls contained, last 7 days" /> <dl className="flex min-w-0 flex-1 flex-col gap-1.5"> {rows.map((row) => ( <div key={row.label} className="flex items-center gap-2 text-13" > <span aria-hidden="true" className={cn( "size-2 shrink-0 rounded-sm", row.swatch, )} /> <dt className="min-w-0 truncate text-muted-foreground"> {row.label} </dt> <dd className="ml-auto font-medium tabular-nums"> {row.value} </dd> </div> ))} <div className="mt-1 border-t border-border pt-1.5 text-xs text-muted-foreground tabular-nums"> 8,412 calls this week </div> </dl> </div> </section> );}Usage#
Radial chart is a ring that shows one share of a whole: 71% of calls contained, 94% of tickets answered within the first-response SLA, 4 of 6 setup steps done. The fill is Ink 65 on a Well Gray track, with the figure in the middle, and it turns green, amber or red only when the share is a verdict against a target. Use it for the one ratio a panel is about, next to a legend or a sentence that explains it. It is not a donut of several categories and not a gauge for a count with no whole.
When to use
- For the one ratio a panel is about: Containment, 7 days with 71% in the ring and the breakdown beside it.
- For a score out of a fixed maximum, such as a QA score of 92 of 100, in a verdict tone.
- For progress through a small number of whole steps with
segments: 4 of 6 setup steps, 5 of 7 days within SLA. - As a 40px ring on a link card that leads to the rest of a checklist.
When not to use
- For a share that sits in a row of other metrics. Use Stat strip
- For a value against a quota or budget, such as credits used. Use Meter
- For how a value changed over time. Use Mini chart
- For progress through an ordered pipeline with named stages. Use Stepper
- For several categories of one whole. Draw a real chart or a stacked meter. Use Chart
The Ink Fill Rule
The Label-Beside-Color Rule
The Tabular Figures Rule
Anatomy#
- Track. The full circle in Well Gray. Its thickness defaults to one eleventh of the size, at least 3px.
- Arc. The share, from 12 o'clock clockwise, with round caps. Ink 65 by default.
- Label. Optional. The figure in weight 600 and tabular figures: 20px from a 96px ring, 14px from 56px, 12px below.
- Sublabel. Optional. 11px Slate Meta under the label: contained, of 100, within target.
- Segments. With
segments, the ring splits into equal arcs with small gaps; whole steps fill in the tone and the rest stay Well Gray.
Examples#
Tones
Ink for a share. Success, warning and danger when the share is a verdict against a target, each beside a status label.
- ContainmentA share, not a verdict
- QA pass ratePassing
- First-response SLANear target
- W-9s on fileBehind
import { RadialChart } from "@oration/canon/components/radial-chart";import { StatusLabel } from "@oration/canon/components/status-dot";export function Tones() { const rings = [ { tone: "ink" as const, value: 0.71, title: "Containment", status: null, }, { tone: "success" as const, value: 0.92, title: "QA pass rate", status: { tone: "success" as const, text: "Passing" }, }, { tone: "warning" as const, value: 0.84, title: "First-response SLA", status: { tone: "warning" as const, text: "Near target" }, }, { tone: "danger" as const, value: 0.58, title: "W-9s on file", status: { tone: "danger" as const, text: "Behind" }, }, ]; return ( <ul className="flex flex-wrap items-start justify-center gap-10"> {rings.map((ring) => { const percent = `${Math.round(ring.value * 100)}%`; return ( <li key={ring.title} className="flex flex-col items-center gap-2" > <RadialChart value={ring.value} size={72} tone={ring.tone} label={percent} ariaLabel={`${ring.title}, ${percent}`} /> <span className="text-13 font-medium"> {ring.title} </span> {ring.status ? ( <StatusLabel tone={ring.status.tone} className="-mt-1 text-xs" > {ring.status.text} </StatusLabel> ) : ( <span className="-mt-1 text-xs text-muted-foreground"> A share, not a verdict </span> )} </li> ); })} </ul> );}Sizes
size is pixels. The label steps up from 12 to 14 to 20px at 56 and 96px; leave it off on a 40px ring.
import { RadialChart } from "@oration/canon/components/radial-chart";export function Sizes() { return ( <div className="flex flex-wrap items-end justify-center gap-10"> <figure className="flex flex-col items-center gap-2"> <RadialChart value={0.67} size={40} ariaLabel="4 of 6 setup steps done" /> <figcaption className="text-xs text-muted-foreground tabular-nums"> 40, no label </figcaption> </figure> <figure className="flex flex-col items-center gap-2"> <RadialChart value={0.94} size={56} label="94%" ariaLabel="First-response SLA, 94%" /> <figcaption className="text-xs text-muted-foreground tabular-nums"> 56, the default </figcaption> </figure> <figure className="flex flex-col items-center gap-2"> <RadialChart value={0.71} size={96} thickness={9} label="71%" sublabel="contained" ariaLabel="71% of calls contained" /> <figcaption className="text-xs text-muted-foreground tabular-nums"> 96, in a panel </figcaption> </figure> <figure className="flex flex-col items-center gap-2"> <RadialChart value={0.88} size={132} label="88%" sublabel="within target" ariaLabel="First-response SLA attainment, 88%" /> <figcaption className="text-xs text-muted-foreground tabular-nums"> 132, a report's lead figure </figcaption> </figure> </div> );}Segments
segments splits the ring into whole steps for small counts, such as setup steps or days in a week.
import { RadialChart } from "@oration/canon/components/radial-chart";import { StatusLabel } from "@oration/canon/components/status-dot";export function Segments() { return ( <div className="flex flex-wrap items-center justify-center gap-12"> <div className="flex items-center gap-3"> <RadialChart value={4 / 6} size={56} segments={6} label="4" sublabel="of 6" ariaLabel="4 of 6 setup steps done" /> <div className="flex flex-col"> <span className="text-13 font-medium">Setup steps</span> <span className="text-xs text-muted-foreground"> Next: connect the ERP </span> </div> </div> <div className="flex items-center gap-3"> <RadialChart value={5 / 7} size={56} segments={7} tone="warning" label="5" sublabel="of 7" ariaLabel="5 of 7 days within the first-response SLA" /> <div className="flex flex-col"> <span className="text-13 font-medium">Days within SLA</span> <StatusLabel tone="warning" className="text-xs"> 2 days missed </StatusLabel> </div> </div> </div> );}Updating
A new value sweeps from the old one on a 0.5s spring. Change the range to see it.
8,412 calls
import { RadialChart } from "@oration/canon/components/radial-chart";import { SegmentedControl } from "@oration/canon/components/segmented-control";import * as React from "react";export function Updating() { const ranges = { today: { value: 0.74, calls: "1,284" }, week: { value: 0.71, calls: "8,412" }, month: { value: 0.66, calls: "34,907" }, }; const [range, setRange] = React.useState<keyof typeof ranges>("week"); const current = ranges[range]; const percent = `${Math.round(current.value * 100)}%`; return ( <div className="flex flex-col items-center gap-4"> <SegmentedControl label="Date range" value={range} onValueChange={setRange} options={[ { value: "today", label: "Today" }, { value: "week", label: "7 days" }, { value: "month", label: "30 days" }, ]} /> <RadialChart value={current.value} size={96} thickness={9} label={percent} sublabel="contained" ariaLabel={`${percent} of calls contained`} /> <p className="text-xs text-muted-foreground tabular-nums"> {current.calls} calls </p> </div> );}In context
A QA score in a verdict tone beside its status label, and a 40px ring on the Getting started link card, where the card's text carries the count.
Call with Tomás Ferreira at Northwind Freight, Sep 28 at 9:14 AM. The agent confirmed the remittance and gave the ACH trace number.
import { RadialChart } from "@oration/canon/components/radial-chart";import { StatusLabel } from "@oration/canon/components/status-dot";import { ChevronRightIcon } from "lucide-react";import Link from "next/link";export function InContext() { return ( <div className="flex w-full max-w-2xl flex-col gap-4"> <div className="flex flex-col gap-4 rounded-xl bg-card p-4 text-left shadow-border sm:flex-row sm:items-center"> <RadialChart value={0.92} size={96} thickness={9} tone="success" label={ <span className="text-xl font-semibold tabular-nums"> 92 </span> } sublabel="of 100" ariaLabel="QA score 92 of 100" /> <div className="flex min-w-0 flex-1 flex-col gap-1.5"> <div className="flex flex-wrap items-center gap-2"> <StatusLabel tone="success" className="font-medium"> Passed </StatusLabel> <span className="text-13 text-muted-foreground"> Payment status scorecard </span> </div> <p className="text-13 text-pretty"> Call with Tomás Ferreira at Northwind Freight, Sep 28 at 9:14 AM. The agent confirmed the remittance and gave the ACH trace number. </p> </div> </div> <Link href="/design/patterns/education" className="group flex items-center gap-3 rounded-xl bg-card p-4 text-left shadow-border outline-none transition-shadow duration-150 hover:shadow-border-hover focus-visible:ring-3 focus-visible:ring-ring/40" > <RadialChart value={4 / 6} size={40} thickness={4} ariaLabel="4 of 6 setup steps done" /> <span className="flex min-w-0 flex-1 flex-col"> <span className="text-13 font-medium"> Getting started,{" "} <span className="tabular-nums">4 of 6</span> </span> <span className="truncate text-xs text-muted-foreground"> Next: connect the ERP so agents can look up payments </span> </span> <ChevronRightIcon aria-hidden="true" className="size-4 text-muted-foreground transition-transform duration-150 group-hover:translate-x-0.5 motion-reduce:transition-none" /> </Link> </div> );}States#
import { RadialChart } from "@oration/canon/components/radial-chart";export function EdgeStates() { const cases = [ { value: 0, caption: "0", segments: undefined }, { value: 0.5, caption: "50%", segments: undefined }, { value: 1, caption: "100%", segments: undefined }, { value: 0, caption: "0 of 6", segments: 6 }, { value: 3 / 6, caption: "3 of 6", segments: 6 }, { value: 1, caption: "6 of 6", segments: 6 }, ]; return ( <div className="flex flex-wrap items-end justify-center gap-8"> {cases.map((item) => ( <figure key={item.caption} className="flex flex-col items-center gap-2" > <RadialChart value={item.value} size={56} segments={item.segments} ariaLabel={`Setup progress, ${item.caption}`} /> <figcaption className="text-xs text-muted-foreground tabular-nums"> {item.caption} </figcaption> </figure> ))} </div> );}| State | Treatment |
|---|---|
| Empty | A value of 0 draws the track alone. |
| Partial | The arc covers the share, clamped to 0 to 1. |
| Full | A value of 1 closes the ring. |
| Filling | On first mount the arc sweeps from zero on a 0.5s spring with no bounce. Segments fade in one after another over about a third of a second. |
| Updating | A new value animates from the old one on the same spring, so a changed date range reads as a change. |
| Reduced motion | The arc and segments appear at their value with no sweep or fade. |
Behavior#
valueis a share from 0 to 1. Anything outside is clamped and a non-number draws as 0.sizeis the ring's width and height in pixels (56 by default);thicknessoverrides the stroke.segmentsabove 1 turns the ring into that many equal arcs, up to 60. The filled count isvalue × segments, rounded to the nearest whole step.labelandsublabelaccept nodes, so the label can be a larger figure such as a QA score.- The ring is drawn with Motion;
MotionConfig reducedMotion="user"in the app shell turns off the sweep.
Do and don't#
- Transferred
- 22%
- Abandoned
- 7%
Content#
- Label: the figure, rounded to a whole percent unless the decimal matters: 71%, 92, 4.
- Sublabel: one or two words that complete it: contained, of 100, of 6, within target.
ariaLabelsays the whole thing in words, including what it measures and the window: 71% of supplier calls contained, last 7 days.- When the ring is a verdict, write the verdict beside it: Passed, Near target, Behind.
Accessibility#
- The ring is
role="meter"witharia-valuemin0,aria-valuemax100 and the value as a whole percent inaria-valuenowandaria-valuetext. - The label and sublabel are hidden from assistive tech, so put their meaning in
ariaLabel: a QA score of 92 is announced as QA score 92 of 100, 92% only if you write it. - The fill animation respects reduced motion.
- Colored rings sit beside a status label, so a failing score doesn't depend on red.
- A ring inside a link card is described by the card's text; keep
ariaLabelshort there so the link doesn't read twice.
Design tokens#
| Token | Used for |
|---|---|
--muted | The track and empty segments |
ink-65 | The arc, ink tone |
--success | Success tone |
--warning | Warning tone |
--destructive | Danger tone |
--foreground | Label |
--muted-foreground | Sublabel |
API reference#
RadialChart
The ring. role="meter", data-slot="radial-chart".
| Prop | Type | Default | Description |
|---|---|---|---|
valueRequired | number | No default | The share, from 0 to 1. |
ariaLabelRequired | string | No default | What the share measures, in words. |
size | number | 56 | Width and height in pixels. |
thickness | number | max(3, round(size / 11)) | Stroke width in pixels. |
label | ReactNode | No default | The figure in the middle. |
sublabel | ReactNode | No default | A word or two under the label. |
tone | "ink" | "success" | "warning" | "danger" | "ink" | Ink unless the share is a verdict. |
segments | number | No default | Splits the ring into this many whole steps (2 to 60). |
className | string | No default | Merged onto the outer element. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
The accessible value is always a percent. A segmented ring labelled 4 of 6 is announced as 67%, and a score of 92 as 92% unless ariaLabel spells it out.
Segments round the filled count: a value of 0.75 on a 6-segment ring fills 5 segments while aria-valuenow says 75.
Label size steps at fixed ring sizes (96 and 56px). Between them, such as a 72px ring, the label stays 14px, and below 56px a label like 100% crowds the ring; the product leaves the label off at 40px.