Progress
Bars, rings and steps that show how far something has come.
One of 4 components in Progress and states
Variants
Details
What Progress brings along, read from the registry and the source.
- Category
- Progress and states
- Area
- Elements
- Variants
- 4
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/progress.tsx
- Brings along
- utilskiticon
- npm packages
- None
- Languages
- English and German, 6 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The ProgressBarProps interface from components/ui/progress.tsx, with the comments from the code.
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.
Built-in texts
Progress writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 6 texts
| Key | English | German |
|---|---|---|
| progress | English Progress | German Fortschritt |
| loading | English Loading | German Lädt |
| step | English Step | German Schritt |
| of | English of | German von |
| done | English done | German erledigt |
| current | English current | German aktuell |
Accessibility
Read from the source of the component.
- Sets aria-current, aria-label, aria-valuemax, aria-valuemin, aria-valuenow and aria-valuetext, so screen readers announce purpose and state.
- Uses the role progressbar.
- Adds text for screen readers where icons and graphics stand alone.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 9 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 336 lines
'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>
);
}Frequent questions
How do I install Progress?
With aiw add @ai-website/progress. The command puts components/ui/progress.tsx into your project, the code is yours from then on. It brings along icon. It needs no further npm packages.
Which variants does Progress have?
4 variants, Bars, Rings, Steps and Checklist.
Can I translate the texts of Progress?
Yes. Progress carries 6 texts of its own in English and German, such as “Progress”, “Loading” and “Step”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Progress?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Progress with aiw_components and install it with aiw_add @ai-website/progress.