Skip to content
Caseroom

Components · Feedback

Progress

A labelled progress bar, or a sweep while the amount is unknown that stands still under reduced motion.

Uploading assets64%
Storage used72%
Building

Both previews render the same markup; the HTML tab below is that markup without a framework.

Install

React
npx shadcn@latest add https://www.themes.dev/caseroom/r/progress.json
Astro
npx shadcn@latest add https://www.themes.dev/caseroom/r/astro/progress.json

Or copy the files below into your project. They need clsx, tailwind-merge, and the Caseroom theme. The HTML needs only the compiled CSS.

progress.tsx
import { cn } from '@/lib/utils';
import { progress } from './progress.variants';

export interface ProgressProps {
  /** The visible label, also the bar's accessible name. */
  label: string;
  /** 0 to max. Leave it out while the amount is unknown. */
  value?: number;
  max?: number;
  /** Unique on the page; ids the label. */
  id: string;
  className?: string;
}

/** A labelled progress bar (role=progressbar); without a value it shows that work is under way. */
export function Progress({ label, value, max = 100, id, className }: ProgressProps) {
  const pct = value === undefined ? undefined : Math.round((Math.min(max, Math.max(0, value)) / max) * 100);
  return (
    <div className={cn(progress.root, className)}>
      <div className={progress.head}>
        <span id={`${id}-label`} className={progress.label}>{label}</span>
        {pct !== undefined && <span className={progress.value}>{pct}%</span>}
      </div>
      <div role="progressbar" aria-labelledby={`${id}-label`} aria-valuemin={0} aria-valuemax={max} aria-valuenow={value} className={progress.track}>
        {pct === undefined ? <div className={progress.indeterminate}></div> : <div className={progress.bar} style={{ width: `${pct}%` }}></div>}
      </div>
    </div>
  );
}
progress.variants.ts
/** Progress: a labelled bar with role=progressbar and aria-valuenow (or none, while the amount is unknown). The fill
 *  eases to its value; an unknown amount sweeps across, and under reduced motion it is a still, half-filled bar. */
export const progress = {
  root: 'grid w-full gap-2',
  head: 'flex items-baseline justify-between gap-4 text-sm',
  label: 'font-medium',
  value: 'text-muted-foreground tabular-nums',
  track: 'relative h-2 w-full overflow-hidden rounded-full bg-muted',
  bar: 'h-full rounded-full bg-primary transition-[width] duration-500 ease-out',
  indeterminate:
    'absolute inset-y-0 w-2/5 rounded-full bg-primary animate-[caseroom-sweep_1.4s_ease-in-out_infinite] motion-reduce:static motion-reduce:w-1/2 motion-reduce:animate-none',
};