# Progress (Caseroom component)

A labelled progress bar, or a sweep while the amount is unknown that stands still under reduced motion. Black and white, dark mode, shadcn-compatible; React, Astro and plain HTML.

## 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`
- HTML: https://www.themes.dev/caseroom/r/html/progress.html (with https://www.themes.dev/caseroom/r/html/caseroom.css)

Page: https://www.themes.dev/caseroom/components/progress/ · Rules for agents: https://www.themes.dev/caseroom/AGENT.md

## Code

### progress.astro

```astro
---
import { cn } from '@/lib/utils';
import { progress } from './progress.variants';

interface Props {
  /** 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;
  class?: string;
}

const { label, value, max = 100, id, class: className } = Astro.props;
const pct = value === undefined ? undefined : Math.round((Math.min(max, Math.max(0, value)) / max) * 100);
---

<div class={cn(progress.root, className)}>
  <div class={progress.head}>
    <span id={`${id}-label`} class={progress.label}>{label}</span>
    {pct !== undefined && <span class={progress.value}>{pct}%</span>}
  </div>
  <div role="progressbar" aria-labelledby={`${id}-label`} aria-valuemin="0" aria-valuemax={max} aria-valuenow={value} class={progress.track}>
    {pct === undefined ? <div class={progress.indeterminate}></div> : <div class={progress.bar} style={`width: ${pct}%`}></div>}
  </div>
</div>
```

### progress.tsx

```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

```tsx
/** 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',
};
```
