Skip to content
UI Kit
Accent color

Progress

Bars, rings and steps that show how far something has come.

One of 4 components in Progress and states

4 variants

Variants

Bars

Uploads, a stacked storage bar and three sizes.

Rings

One number per ring, with a free middle.

Steps

Numbered, bars and dots for flows with several steps.

Checklist

Onboarding tasks with a ring that follows each tick.

Details

What Progress brings along, read from the registry and the source.

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

Props

The ProgressBarProps interface from components/ui/progress.tsx, with the comments from the code.

  • valueoptional
    number
  • maxoptional
    number
  • labeloptional
    React.ReactNode

    Text above the bar, e.g. the file name

  • valueTextoptional
    React.ReactNode

    Shown right of the label. Defaults to the percentage when showValue is set.

  • showValueoptional
    boolean
  • sizeoptional
    'sm' | 'md' | 'lg'
  • toneoptional
    ProgressTone

    accentsuccesswarningdestructiveneutral

  • indeterminateoptional
    boolean

    Unknown 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
progressEnglish ProgressGerman Fortschritt
loadingEnglish LoadingGerman Lädt
stepEnglish StepGerman Schritt
ofEnglish ofGerman von
doneEnglish doneGerman erledigt
currentEnglish currentGerman 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
components/ui/progress.tsx
'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.

Often searched

Jump to

Copied to the clipboard