Skip to content

Radial chart

A ring that shows one ratio, with a centered label and optional segments.

Status
Beta
Category
Data display
Adoption
Not used yet
import { RadialChart } from "@oration/canon/components/radial-chart";
packages/canon/src/components/radial-chart.tsx

Containment, 7 days

Contained
71%
Transferred
22%
Abandoned
7%
8,412 calls this week
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 arc is Ink 65 on a Well Gray track. Success, warning and danger tones are for a share that is a verdict against a target, such as a passing QA score. It is never indigo.

The Label-Beside-Color Rule

A colored ring always sits beside words that give the verdict: Passed, Near target, Behind. The figure in the middle is not enough on its own.

The Tabular Figures Rule

The label and sublabel are set in tabular figures, so a ring that updates doesn't jitter.

Anatomy#

  1. Track. The full circle in Well Gray. Its thickness defaults to one eleventh of the size, at least 3px.
  2. Arc. The share, from 12 o'clock clockwise, with round caps. Ink 65 by default.
  3. Label. Optional. The figure in weight 600 and tabular figures: 20px from a 96px ring, 14px from 56px, 12px below.
  4. Sublabel. Optional. 11px Slate Meta under the label: contained, of 100, within target.
  5. 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.

40, no label
56, the default
96, in a panel
132, a report's lead figure
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.

Setup stepsNext: connect the ERP
Days within SLA2 days missed
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.

PassedPayment status scorecard

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.

Getting started, 4 of 6Next: connect the ERP so agents can look up payments
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#

0
50%
100%
0 of 6
3 of 6
6 of 6
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>    );}
States
StateTreatment
EmptyA value of 0 draws the track alone.
PartialThe arc covers the share, clamped to 0 to 1.
FullA value of 1 closes the ring.
FillingOn 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.
UpdatingA new value animates from the old one on the same spring, so a changed date range reads as a change.
Reduced motionThe arc and segments appear at their value with no sweep or fade.

Behavior#

  • value is a share from 0 to 1. Anything outside is clamped and a non-number draws as 0.
  • size is the ring's width and height in pixels (56 by default); thickness overrides the stroke.
  • segments above 1 turns the ring into that many equal arcs, up to 60. The filled count is value × segments, rounded to the nearest whole step.
  • label and sublabel accept 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%
Do. Show one ratio per ring, with the rest of the breakdown written beside it.
Don't. Nest rings for several categories. Concentric arcs can't be compared and the colors say nothing.
First-response SLA
Do. Use a ring for a share of a whole, with its percentage in the middle.
Calls today
Don't. Put a count in a ring. 1,284 calls isn't a share of anything, so the arc is invented.

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.
  • ariaLabel says 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" with aria-valuemin 0, aria-valuemax 100 and the value as a whole percent in aria-valuenow and aria-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 ariaLabel short there so the link doesn't read twice.

Design tokens#

Design tokens
TokenUsed for
--mutedThe track and empty segments
ink-65The arc, ink tone
--successSuccess tone
--warningWarning tone
--destructiveDanger tone
--foregroundLabel
--muted-foregroundSublabel

API reference#

RadialChart

The ring. role="meter", data-slot="radial-chart".

Props of RadialChart
PropTypeDefaultDescription
valueRequirednumberNo defaultThe share, from 0 to 1.
ariaLabelRequiredstringNo defaultWhat the share measures, in words.
sizenumber56Width and height in pixels.
thicknessnumbermax(3, round(size / 11))Stroke width in pixels.
labelReactNodeNo defaultThe figure in the middle.
sublabelReactNodeNo defaultA word or two under the label.
tone"ink" | "success" | "warning" | "danger""ink"Ink unless the share is a verdict.
segmentsnumberNo defaultSplits the ring into this many whole steps (2 to 60).
classNamestringNo defaultMerged 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.