RadialStat

RadialStat shows one bounded number as a ring. The arc starts at 12 o'clock and fills clockwise in proportion to value / max. The number sits in the center. The color of the ring reports the tier of the value: red, orange, or green. The color is the state. There is no neutral mode.

It is built on Base UI's Meter, so the element carries meter semantics. Screen readers announce the raw value, with the postfix when one is set.

import RadialStat from "@haloid/azucar/ui/radial-stat";

Tiers

Two thresholds split the range into three tiers. Below the first boundary is bad. Below the second is warn. The rest is good. A value equal to a boundary lands in the higher tier. Higher is always better. There is no inverted mode.

Each tier draws a step-4 indicator over a step-2 track from the same ramp: red-4 over red-2, orange-4 over orange-2, green-4 over green-2. The number stays fg-4. Only the ring carries the signal.

<div className="flex items-center gap-16">
<RadialStat value={42} postfix="%" thresholds={[60, 90]} />
<RadialStat value={74} postfix="%" thresholds={[60, 90]} />
<RadialStat value={97} postfix="%" thresholds={[60, 90]} />
</div>

The default thresholds sit at half and eighty percent of max. They are a fallback, not a decision. Real screens pass real boundaries. The accuracy ring below takes its pair from one shared constant.

This is one of the few places where azucar allows color. The color page uses this same row to make the case: an operator reads the tier before the number, so the color is information, not decoration.

Size

size sets the outer square in pixels. The rest derives from it. The stroke defaults to a tenth of the size. The label font is a quarter of it. Change the size and the ring keeps its proportions.

value
size
<RadialStat value={74} postfix="%" thresholds={[60, 90]} />

Pass thickness to break the ratio. The verified accuracy card uses thickness={7} at the default size: a thinner ring that does not outweigh the copy beside it.

The label prints value as passed, then the postfix. It does not round and it does not format. Format before you pass. A long decimal crowds the center at small sizes.

The verified accuracy card

The reconciliation overview reports verified accuracy with this ring. The composition lives in apps/north/src/components/pages/reconciliation-overview/overview-stats.tsx:

<CardContent>
<div className="flex items-center gap-3">
<RadialStat
value={Number.parseInt(accuracy.current.toFixed(0))}
postfix="%"
thresholds={RECONCILIATION_ACCURACY_THRESHOLDS}
thickness={7}
/>
<div className="flex flex-col gap-1">
<h2>{band.title}</h2>
<p className="text-[14px] text-muted-foreground text-pretty">
{band.description}
</p>
</div>
</div>
</CardContent>

Copy three choices from this composition. The thresholds come from RECONCILIATION_ACCURACY_THRESHOLDS in packages/azucar/src/lib/reconciliation-accuracy.ts, so the ring and every other accuracy readout agree on where the tiers sit. The value is rounded to an integer before it reaches the ring, because the label prints what it gets. The ring never stands alone: it sits in a flex items-center gap-3 row next to a title and one sentence of copy. The Card page shows this composition live.

API

interface Props {
value: number; // printed as passed; format before passing
max?: number; // default 100
size?: number; // outer square in px; default 64
postfix?: string; // appended to the label and to aria-valuetext
thickness?: number; // ring stroke in px; default size * 0.1
thresholds?: [number, number]; // [bad|warn, warn|good] in value units; default [max * 0.5, max * 0.8]
className?: string;
}

Values outside the range clamp the arc only. The ring caps at empty or full, the label shows the raw number, and assistive tech hears the raw number too.

Not supported: a lower-is-better mode, and a ring without tiers. Every ring carries a status color, so a number with no good or bad does not belong here. Use NumberedStat for that. The arc jumps to a new value with no transition, which matches how the rest of azucar treats motion.