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
Details
Was Ringfortschritt mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- KPI-, Live- und Karten-Charts
- 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
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
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.
dataPflichtRadialItem[]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
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
'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.