Fortschritt
Balken, Ringe und Schritte, die zeigen, wie weit etwas ist.
Eine von 4 Components in Fortschritt und Zustände
Variants
Details
Was Fortschritt mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Fortschritt und Zustände
- Bereich
- Elements
- Variants
- 4
- Version
- 1.0.0 · seit 1.0.0
- Datei
- components/ui/progress.tsx
- Bringt mit
- utilskiticon
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 6 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle ProgressBarProps aus components/ui/progress.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
valueoptionalnumbermaxoptionalnumberlabeloptionalReact.ReactNodeText above the bar, e.g. the file name
valueTextoptionalReact.ReactNodeShown right of the label. Defaults to the percentage when showValue is set.
showValueoptionalbooleansizeoptional'sm' | 'md' | 'lg'toneoptionalProgressToneaccentsuccesswarningdestructiveneutral
indeterminateoptionalbooleanUnknown duration: a light runs through the track
segmentsoptional{ value: number; label: string }[]Several parts in one bar, e.g. storage by type. Values share max.
Eingebaute Texte
Diese Texte schreibt Fortschritt selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 6 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| progress | Englisch Progress | Deutsch Fortschritt |
| loading | Englisch Loading | Deutsch Lädt |
| step | Englisch Step | Deutsch Schritt |
| of | Englisch of | Deutsch von |
| done | Englisch done | Deutsch erledigt |
| current | Englisch current | Deutsch aktuell |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-current, aria-label, aria-valuemax, aria-valuemin, aria-valuenow und aria-valuetext, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rolle progressbar.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 9 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 336 Zeilen
'use client';
import { useEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useInView, useLocale, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
export type ProgressTone = 'accent' | 'success' | 'warning' | 'destructive' | 'neutral';
const FILL: Record<ProgressTone, string> = {
accent: 'bg-primary',
success: 'bg-success',
warning: 'bg-warning',
destructive: 'bg-destructive',
neutral: 'bg-foreground',
};
const STROKE: Record<ProgressTone, string> = {
accent: 'var(--primary)',
success: 'var(--success)',
warning: 'var(--warning)',
destructive: 'var(--destructive)',
neutral: 'var(--foreground)',
};
/** Graded accent tones for stacked segments, strongest first */
const SEGMENT = [100, 62, 38, 22, 12];
const TEXTS = {
en: { progress: 'Progress', loading: 'Loading', step: 'Step', of: 'of', done: 'done', current: 'current' },
de: { progress: 'Fortschritt', loading: 'Lädt', step: 'Schritt', of: 'von', done: 'erledigt', current: 'aktuell' },
};
type ProgressTexts = (typeof TEXTS)['en'];
const clamp = (v: number, max: number) => Math.min(Math.max(v / (max || 1), 0), 1);
function usePercent() {
const locale = useLocale();
const nf = new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { style: 'percent', maximumFractionDigits: 0 });
return (share: number) => nf.format(share);
}
const KEYFRAMES = `@keyframes kitdd-indeterminate{0%{transform:translateX(-100%)}100%{transform:translateX(250%)}}`;
/* Steps pop in one after another, a check that arrives springs a little */
const STYLES =
'.kitfx-pr-in .kitfx-pr-step{animation:kitfx-pr-step .55s cubic-bezier(.34,1.45,.64,1) both;animation-delay:calc(var(--i,0) * 110ms)}' +
'@keyframes kitfx-pr-step{from{opacity:0;transform:scale(.6)}}' +
'@media (prefers-reduced-motion:reduce){.kitfx-pr-in .kitfx-pr-step{animation:none}}';
/** Entrance only for elements that were below the fold, so nothing above it blinks on load */
function useEntrance<T extends Element>() {
const [ref, inView] = useInView<T>();
const waited = useRef(false);
if (!inView) waited.current = true;
return [ref, inView, inView && waited.current] as const;
}
/** A number that runs from 0 to its value once `run` turns true. Starts at the value for the server and reduced motion. */
function useCount(value: number, run: boolean, ms = 1000) {
const [n, setN] = useState(value);
const done = useRef(false);
useEffect(() => {
if (!run || done.current || prefersReducedMotion()) return;
done.current = true;
let raf = 0;
const start = performance.now();
const tick = (now: number) => {
const t = Math.min(1, (now - start) / ms);
setN(value * (1 - Math.pow(1 - t, 3)));
if (t < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [run, value, ms]);
return done.current || run ? n : value;
}
/* ------------------------------------------------------------------ */
export interface ProgressBarProps {
value?: number;
max?: number;
/** Text above the bar, e.g. the file name */
label?: React.ReactNode;
/** Shown right of the label. Defaults to the percentage when showValue is set. */
valueText?: React.ReactNode;
showValue?: boolean;
size?: 'sm' | 'md' | 'lg';
tone?: ProgressTone;
/** Unknown duration: a light runs through the track */
indeterminate?: boolean;
/** Several parts in one bar, e.g. storage by type. Values share max. */
segments?: { value: number; label: string }[];
labels?: Partial<ProgressTexts>;
className?: string;
}
const HEIGHT = { sm: 'h-1', md: 'h-1.5', lg: 'h-2.5' };
/** A horizontal bar. Fills up when it scrolls into view, the percentage counts along. */
export function ProgressBar({ value = 0, max = 100, label, valueText, showValue, size = 'md', tone = 'accent', indeterminate, segments, labels, className }: ProgressBarProps) {
const t = useTexts(TEXTS, labels);
const pct = usePercent();
const [ref, inView, entering] = useEntrance<HTMLDivElement>();
const share = clamp(segments ? segments.reduce((s, x) => s + x.value, 0) : value, max);
const counted = useCount(share, entering, 1000);
const shown = valueText ?? (showValue && !indeterminate ? pct(entering ? counted : share) : null);
const name = typeof label === 'string' ? label : t.progress;
return (
<div ref={ref} className={cn('w-full', className)}>
{(label || shown) && (
<div className="mb-2 flex items-baseline justify-between gap-3 text-[13px]">
{label && <span className="min-w-0 truncate font-medium text-foreground">{label}</span>}
{shown && <span className="shrink-0 tabular-nums text-muted-foreground">{shown}</span>}
</div>
)}
<div
role="progressbar"
aria-label={name}
aria-valuemin={0}
aria-valuemax={indeterminate ? undefined : 100}
aria-valuenow={indeterminate ? undefined : round(share * 100, 0)}
aria-valuetext={indeterminate ? t.loading : undefined}
className={cn('relative w-full overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)]', HEIGHT[size])}
>
{indeterminate ? (
<>
<style>{KEYFRAMES}</style>
<span className={cn('absolute inset-y-0 left-0 w-2/5 rounded-full animate-[kitdd-indeterminate_1.6s_var(--ease-kit)_infinite] motion-reduce:left-[30%] motion-reduce:animate-none motion-reduce:opacity-60', FILL[tone])} />
</>
) : segments ? (
// The parts keep their widths, a clip opens over them from the start
<span className="flex h-full gap-[2px] transition-[clip-path] duration-[1200ms] ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ clipPath: `inset(0 ${inView ? 0 : 100}% 0 0)` }}>
{segments.map((sgm, i) => (
<span
key={sgm.label}
className="h-full first:rounded-l-full last:rounded-r-full"
style={{ width: `${round(clamp(sgm.value, max) * 100)}%`, background: `color-mix(in oklab, ${STROKE[tone]} ${SEGMENT[i % SEGMENT.length]}%, var(--background))` }}
/>
))}
</span>
) : (
// The fill is full width and slides in from the start, so its rounded end keeps its shape
<span
data-clip-ok
className={cn('absolute inset-0 rounded-full transition-transform duration-1000 ease-[var(--ease-kit)] motion-reduce:transition-none', FILL[tone])}
style={{ transform: `translateX(${inView ? round((share - 1) * 100) : -100}%)` }}
/>
)}
</div>
{segments && (
<ul className="mt-3 flex flex-wrap gap-x-5 gap-y-1.5 text-[12px] text-muted-foreground">
{segments.map((sgm, i) => (
<li key={sgm.label} className="flex items-center gap-1.5">
<span className="size-2 rounded-full" style={{ background: `color-mix(in oklab, ${STROKE[tone]} ${SEGMENT[i % SEGMENT.length]}%, var(--background))` }} aria-hidden />
{sgm.label}
<span className="tabular-nums text-subtle-foreground">{pct(clamp(sgm.value, max))}</span>
</li>
))}
</ul>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
export interface ProgressRingProps {
value: number;
max?: number;
/** Diameter in px */
size?: number;
/** Stroke width in px */
thickness?: number;
tone?: ProgressTone;
/** Accessible name, e.g. "Monthly goal" */
label?: string;
/** Content in the middle. Defaults to the percentage. */
children?: React.ReactNode;
className?: string;
}
/** A ring that fills clockwise from the top when it scrolls into view, the percentage counts along. */
export function ProgressRing({ value, max = 100, size = 96, thickness = 8, tone = 'accent', label, children, className }: ProgressRingProps) {
const t = useTexts(TEXTS);
const pct = usePercent();
const [ref, inView, entering] = useEntrance<HTMLDivElement>();
const share = clamp(value, max);
const counted = useCount(share, entering, 1200);
const r = (size - thickness) / 2;
const c = 2 * Math.PI * r;
return (
<div
ref={ref}
role="progressbar"
aria-label={label ?? t.progress}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={round(share * 100, 0)}
className={cn('relative inline-grid shrink-0 place-items-center', className)}
style={{ width: size, height: size }}
>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="-rotate-90" aria-hidden>
<circle cx={size / 2} cy={size / 2} r={round(r)} fill="none" strokeWidth={thickness} style={{ stroke: 'color-mix(in oklab, var(--foreground) 8%, transparent)' }} />
<circle
cx={size / 2}
cy={size / 2}
r={round(r)}
fill="none"
strokeWidth={thickness}
strokeLinecap="round"
strokeDasharray={round(c)}
strokeDashoffset={round(inView ? c * (1 - share) : c)}
className="transition-[stroke-dashoffset] duration-[1200ms] ease-[var(--ease-kit)] motion-reduce:transition-none"
style={{ stroke: STROKE[tone] }}
/>
</svg>
<span className="absolute inset-0 grid place-items-center text-center">
{children ?? (
<span className="font-semibold tabular-nums tracking-tight text-foreground" style={{ fontSize: round(Math.max(9, size * 0.16)) }}>
{pct(entering ? counted : share)}
</span>
)}
</span>
</div>
);
}
/* ------------------------------------------------------------------ */
export interface ProgressStepsProps {
steps: string[];
/** Index of the current step, steps before it count as done */
current: number;
variant?: 'numbered' | 'bars' | 'dots';
labels?: Partial<ProgressTexts>;
className?: string;
}
/** Steps of a flow: numbered with connectors, bars or dots. They fill one after another when they scroll into view. */
export function ProgressSteps({ steps, current, variant = 'numbered', labels, className }: ProgressStepsProps) {
const t = useTexts(TEXTS, labels);
const [ref, inView, entering] = useEntrance<HTMLDivElement & HTMLOListElement>();
const lag = (i: number) => ({ transitionDelay: entering ? `${i * 160}ms` : undefined });
const style = (
<style href="kitfx-pr" precedence="default">
{STYLES}
</style>
);
const state = (i: number) => (i < current ? 'done' : i === current ? 'current' : 'upcoming');
const summary = `${t.step} ${current + 1} ${t.of} ${steps.length}`;
if (variant === 'dots') {
return (
<div ref={ref} className={cn('flex items-center gap-3', !inView && '[&_.kitfx-pr-step]:opacity-0', entering && 'kitfx-pr-in', className)}>
{style}
<ol className="flex items-center gap-1.5" aria-label={summary}>
{steps.map((s, i) => (
<li
key={s}
aria-current={i === current ? 'step' : undefined}
style={{ '--i': i } as React.CSSProperties}
className={cn('kitfx-pr-step h-1.5 rounded-full transition-colors duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', i === current ? 'w-6 bg-primary' : i < current ? 'w-1.5 bg-[color-mix(in_oklab,var(--primary)_55%,var(--background))]' : 'w-1.5 bg-border-strong')}
>
<span data-clip-ok className="sr-only">
{s}, {state(i) === 'done' ? t.done : state(i) === 'current' ? t.current : ''}
</span>
</li>
))}
</ol>
<span className="text-[13px] tabular-nums text-muted-foreground">{summary}</span>
</div>
);
}
if (variant === 'bars') {
return (
<div ref={ref} className={cn('w-full', className)}>
<div className="mb-3 flex items-baseline justify-between gap-3 text-[13px] sm:hidden">
<span className="font-medium text-foreground">{steps[current]}</span>
<span className="tabular-nums text-muted-foreground">{summary}</span>
</div>
<ol className="grid gap-2" style={{ gridTemplateColumns: `repeat(${steps.length}, minmax(0, 1fr))` }} aria-label={summary}>
{steps.map((s, i) => (
<li key={s} aria-current={i === current ? 'step' : undefined}>
<span className="block h-1.5 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)]">
<span
data-clip-ok
className="block h-full w-full rounded-full bg-primary transition-transform duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none"
style={{ transform: `translateX(${inView ? (i < current ? 0 : i === current ? -50 : -100) : -100}%)`, ...lag(i) }}
/>
</span>
<span className={cn('mt-2.5 hidden truncate text-[13px] sm:block', i <= current ? 'font-medium text-foreground' : 'text-muted-foreground')}>{s}</span>
<span data-clip-ok className="sr-only sm:hidden">{s}</span>
</li>
))}
</ol>
</div>
);
}
return (
<ol ref={ref} className={cn('flex w-full items-start', !inView && '[&_.kitfx-pr-step]:opacity-0', entering && 'kitfx-pr-in', className)} aria-label={summary}>
{style}
{steps.map((s, i) => {
const st = state(i);
return (
<li key={s} aria-current={st === 'current' ? 'step' : undefined} className={cn('relative flex min-w-0 flex-1 flex-col items-center text-center')}>
{i < steps.length - 1 && (
<span aria-hidden className="absolute left-[calc(50%+1.25rem)] right-[calc(-50%+1.25rem)] top-4 h-px overflow-hidden bg-border-strong">
<span
className={cn('block h-full w-full origin-left bg-primary transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', st === 'done' && inView ? 'scale-x-100' : 'scale-x-0')}
style={{ transitionDelay: entering ? `${i * 110 + 160}ms` : undefined }}
/>
</span>
)}
<span
style={{ '--i': i } as React.CSSProperties}
className={cn(
'kitfx-pr-step relative grid size-8 place-items-center rounded-full text-[13px] font-semibold tabular-nums transition-colors',
st === 'done' && 'bg-primary text-primary-foreground',
st === 'current' && 'bg-background text-foreground shadow-[inset_0_0_0_1.5px_var(--primary),0_0_0_4px_color-mix(in_oklab,var(--primary)_14%,transparent)]',
st === 'upcoming' && 'bg-background text-muted-foreground shadow-[inset_0_0_0_1px_var(--border-strong)]',
)}
>
{st === 'done' ? <Icon name="check" size={15} strokeWidth={2.2} /> : i + 1}
</span>
<span className={cn('mt-2.5 max-w-full truncate px-1 text-[12px] sm:text-[13px]', st === 'upcoming' ? 'text-muted-foreground' : 'font-medium text-foreground', st !== 'current' && 'max-sm:sr-only')}>
{s}
<span data-clip-ok className="sr-only">, {st === 'done' ? t.done : st === 'current' ? t.current : ''}</span>
</span>
</li>
);
})}
</ol>
);
}Häufige Fragen
Wie installiere ich Fortschritt?
Mit aiw add @ai-website/progress. Der Befehl legt die Datei components/ui/progress.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Fortschritt?
4 Varianten, Balken, Ringe, Schritte und Checkliste.
Kann ich die Texte von Fortschritt übersetzen?
Ja. Fortschritt bringt 6 eigene Texte auf Deutsch und Englisch mit, etwa „Fortschritt“, „Lädt“ und „Schritt“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Fortschritt einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Fortschritt mit aiw_components und installieren sie mit aiw_add @ai-website/progress.