Skip to content
    Style: Wireframe

    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/ui/r/progress.json
    Astro
    npx shadcn@latest add https://www.themes.dev/ui/r/astro/progress.json

    Or copy the files below into your project. They need clsx, tailwind-merge, and the themes.dev UI 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',
    };