Radial progress
Progress towards a goal as a single ring with the share in the middle, or several goals as concentric rings.
One of 6 components in KPI, live and map charts
Details
What Radial progress brings along, read from the registry and the source.
- Category
- KPI, live and map charts
- Area
- Elements
- Variants
- 2
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/radial-progress.tsx
- Brings along
- utilskitchart
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The RadialProgressProps interface from components/ui/radial-progress.tsx, with the comments from the code. Also takes every prop of ChartA11y.
datarequiredRadialItem[]One ring per item, the first outside. One item draws a single ring with the share in the middle.
formatoptionalIntl.NumberFormatOptionsValue format in legend, tooltip and table, e.g. { style: 'unit', unit: 'hour' }
centerValueoptionalstringBig text in the middle. Default: the share of a single ring, nothing for several rings.
centerLabeloptionalstringSmall text under it. Default: the label of a single ring.
sizeoptionalnumberDiameter in px. It shrinks with the container.
thicknessoptionalnumberRing width in px
legendoptional'side' | 'bottom' | falseLegend for several rings: beside the rings (side), below (bottom) or none
Accessibility
Read from the source of the component.
- Adds text for screen readers where icons and graphics stand alone.
- Needs no client state. The server writes it in full, so it reads without JavaScript too.
Motion
- With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 110 lines
'use client';
import { cn, round } from '@/lib/kit';
import { ChartLegend, ChartRoot, CountUp, ChartSurface, ChartTable, arcPath, numberFormatter, seriesColor, useActive, useEntrance, useIntlLocale, type ChartA11y } from '@/components/ui/chart';
export interface RadialItem {
label: string;
value: number;
/** Goal of this ring. Default 100. */
max?: number;
/** Any CSS color. Default: the slot color in order (accent, grey, two accent tones). */
color?: string;
}
export interface RadialProgressProps extends ChartA11y {
/** One ring per item, the first outside. One item draws a single ring with the share in the middle. */
data: RadialItem[];
/** Value format in legend, tooltip and table, e.g. { style: 'unit', unit: 'hour' } */
format?: Intl.NumberFormatOptions;
/** Big text in the middle. Default: the share of a single ring, nothing for several rings. */
centerValue?: string;
/** Small text under it. Default: the label of a single ring. */
centerLabel?: string;
/** Diameter in px. It shrinks with the container. */
size?: number;
/** Ring width in px */
thickness?: number;
/** Legend for several rings: beside the rings (side), below (bottom) or none */
legend?: 'side' | 'bottom' | false;
className?: string;
}
/**
* Progress towards a goal as a ring, or several goals as concentric rings.
* <RadialProgress label="Profile" data={[{ label: 'Profile complete', value: 72 }]} />
*/
export function RadialProgress({ data, format, centerValue, centerLabel, size = 160, thickness, legend = 'side', label, summary, labels, animate = true, className }: RadialProgressProps) {
const intl = useIntlLocale();
const fmt = numberFormatter(intl, format);
const pct = numberFormatter(intl, { style: 'percent', maximumFractionDigits: 0 });
const [rootRef, anim] = useEntrance<HTMLDivElement>(animate);
const { active, setActive, onKeyDown, onFocus, onBlur } = useActive(data.length);
const single = data.length === 1;
const w = thickness ?? (single ? round(size * 0.075, 0) : round(Math.min(size * 0.07, (size * 0.32) / data.length), 0));
const gap = single ? 0 : Math.max(3, round(w * 0.3, 0));
const c = round(size / 2);
const share = (it: RadialItem) => Math.max(0, Math.min(1, it.value / (it.max ?? 100)));
const text = (it: RadialItem) => (it.max !== undefined && it.max !== 100 ? `${fmt(it.value)} / ${fmt(it.max)}` : fmt(it.value));
const live = active !== null ? `${data[active].label}, ${text(data[active])}, ${pct(share(data[active]))}` : '';
const auto = data.map((it) => `${it.label} ${pct(share(it))}`).join(', ');
// A single ring counts its share up while it draws
const mid: React.ReactNode = active !== null && !single ? pct(share(data[active])) : centerValue ?? (single ? <CountUp value={round(share(data[0]), 4)} format={pct} anim={anim} duration={900} /> : undefined);
const sub = active !== null && !single ? data[active].label : centerLabel ?? (single ? data[0].label : undefined);
return (
<ChartRoot anim={anim} rootRef={rootRef} className={className}>
<div className={cn('flex flex-col items-center gap-6', !single && legend === 'side' && 'sm:flex-row sm:gap-10')}>
<ChartSurface
label={label}
summary={summary ?? auto}
labels={labels}
live={live}
table={<ChartTable caption={label} head={['', label, '%']} rows={data.map((it) => [it.label, text(it), pct(share(it))])} />}
onKeyDown={onKeyDown}
onFocus={onFocus}
onBlur={onBlur}
onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}
className="w-full shrink-0 rounded-full"
style={{ maxWidth: size }}
>
<svg viewBox={`0 0 ${size} ${size}`} width="100%" aria-hidden className="block">
{data.map((it, i) => {
const r = round(size / 2 - w / 2 - 2 - i * (w + gap));
if (r <= w / 2) return null;
const s = share(it);
const color = seriesColor(i, it.color);
return (
<g key={it.label} opacity={active !== null && active !== i ? 0.3 : 1} className="transition-opacity duration-200 motion-reduce:transition-none" onPointerEnter={() => setActive(i)} onPointerDown={() => setActive(i)}>
<circle cx={c} cy={c} r={r} fill="none" stroke="var(--kitch-empty)" strokeWidth={w} />
{s > 0 && (
s >= 0.9999 ? (
<circle cx={c} cy={c} r={r} fill="none" stroke={color} strokeWidth={w} pathLength={1} transform={`rotate(-90 ${c} ${c})`} className={anim ? 'kitch-draw' : undefined} style={anim ? ({ '--kitch-d': `${i * 140}ms` } as React.CSSProperties) : undefined} />
) : (
<path d={arcPath(c, c, r, 0, s * 360)} fill="none" stroke={color} strokeWidth={w} strokeLinecap="round" pathLength={1} className={anim ? 'kitch-draw' : undefined} style={anim ? ({ '--kitch-d': `${i * 140}ms` } as React.CSSProperties) : undefined} />
)
)}
</g>
);
})}
</svg>
{mid && (
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center">
<span className={cn('font-semibold leading-tight tracking-tight text-foreground', size >= 140 ? 'text-[24px]' : 'text-[17px]')}>{mid}</span>
{sub && <span className={cn('mt-0.5 max-w-[62%] text-[12px] leading-snug text-muted-foreground', size < 140 && 'sr-only')}>{sub}</span>}
</div>
)}
</ChartSurface>
{!single && legend && (
<ChartLegend
layout="list"
className={cn('w-full', legend === 'side' ? 'sm:max-w-[19rem]' : 'max-w-[22rem]')}
active={active}
onActive={setActive}
items={data.map((it, i) => ({ label: it.label, color: seriesColor(i, it.color), shape: 'dot', value: text(it), note: pct(share(it)) }))}
/>
)}
</div>
</ChartRoot>
);
}Frequent questions
How do I install Radial progress?
With aiw add @ai-website/radial-progress. The command puts components/ui/radial-progress.tsx into your project, the code is yours from then on. It brings along chart. It needs no further npm packages.
Which variants does Radial progress have?
2 variants, Single ring and Concentric rings.
Can an AI assistant build with Radial progress?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Radial progress with aiw_components and install it with aiw_add @ai-website/radial-progress.