Zum Inhalt springen
UI Kit
Accent

Ringfortschritt

Fortschritt zu einem Ziel als einzelner Ring mit dem Anteil in der Mitte, oder mehrere Ziele als Ringe ineinander.

Eine von 6 Components in KPI-, Live- und Karten-Charts

2 Variants

Variants

Einzelner Ring

Ein Ziel je Ring, der Anteil in der Mitte, die absoluten Werte darunter.

Ringe ineinander

Drei Wochenziele in einer Figur, die Legende trägt Werte und Anteile.

Details

Was Ringfortschritt mitbringt, aus Registry und Quellcode gelesen.

Bereich
Elements
Variants
2
Version
1.0.0 · seit 1.0.0
Datei
components/ui/radial-progress.tsx
Bringt mit
utilskitchart
npm-Pakete
Keine
Sprachen
Texte kommen über Props
Studio
Im Code anpassen

Props

Die Schnittstelle RadialProgressProps aus components/ui/radial-progress.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von ChartA11y an.

  • dataPflicht
    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

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Kommt ohne Client-State aus. Der Server schreibt sie vollständig, sie ist auch ohne JavaScript lesbar.

Bewegung

  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.
  • Ohne Animationen, sie steht sofort ruhig da.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 110 Zeilen
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>
  );
}

Häufige Fragen

Wie installiere ich Ringfortschritt?

Mit aiw add @ai-website/radial-progress. Der Befehl legt die Datei components/ui/radial-progress.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind chart. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Ringfortschritt?

2 Varianten, Einzelner Ring und Ringe ineinander.

Kann ein KI-Assistent Ringfortschritt einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Ringfortschritt mit aiw_components und installieren sie mit aiw_add @ai-website/radial-progress.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert