Skip to content
UI Kit
Accent color

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

2 variants

Variants

Single ring

One goal per ring, the share in the middle, the absolute values below.

Concentric rings

Three weekly goals in one figure, the legend carries values and shares.

Details

What Radial progress brings along, read from the registry and the source.

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

Props

The RadialProgressProps interface from components/ui/radial-progress.tsx, with the comments from the code. Also takes every prop of ChartA11y.

  • datarequired
    RadialItem[]

    One ring per item, the first outside. One item draws a single ring with the share in the middle.

  • formatoptional
    Intl.NumberFormatOptions

    Value format in legend, tooltip and table, e.g. { style: 'unit', unit: 'hour' }

  • centerValueoptional
    string

    Big text in the middle. Default: the share of a single ring, nothing for several rings.

  • centerLabeloptional
    string

    Small text under it. Default: the label of a single ring.

  • sizeoptional
    number

    Diameter in px. It shrinks with the container.

  • thicknessoptional
    number

    Ring width in px

  • legendoptional
    'side' | 'bottom' | false

    Legend 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
components/ui/radial-progress.tsx
'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.

Often searched

Jump to

Copied to the clipboard