# Progress

Linear and circular progress on a spring, with an optional indeterminate sweep.

**Category:** Feedback  
**Exports:** `Progress`, `ProgressRing`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/progress.json
```
The file lands at `components/ui/progress.tsx`; import from `@/components/ui/progress`.

## API — Progress
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `value` | `number` | — | 0–100. Omit for indeterminate. |
| `label` | `string` | — |  |
| `showValue` | `boolean` | `false` |  |
| `ProgressRing size / stroke` | `number / number` | `48 / 4` |  |

## Example — Progress

```tsx
import * as React from "react";
import { Progress, ProgressRing } from "@/components/ui/progress";

export default function ProgressDemo({
  label = "Uploading assets",
  showValue = true,
  size = 64,
  stroke = 6,
}: {
  label?: string;
  showValue?: boolean;
  size?: number;
  stroke?: number;
}) {
  const [value, setValue] = React.useState(12);
  React.useEffect(() => {
    const t = setInterval(() => setValue((v) => (v >= 100 ? 0 : Math.min(100, v + Math.random() * 18))), 900);
    return () => clearInterval(t);
  }, []);
  return (
    <div className="grid w-full max-w-sm gap-8">
      <Progress label={label} value={value} showValue={showValue} />
      <Progress label="Waiting for the server" />
      <div className="flex items-center gap-4">
        <ProgressRing value={value} label="Upload progress" />
        <ProgressRing value={72} size={size} stroke={stroke} label="Storage used" />
      </div>
    </div>
  );
}
```

## Source — components/ui/progress.tsx

```tsx
"use client";

import * as React from "react";
import { motion, useReducedMotion, useSpring, useTransform } from "motion/react";
import { cn } from "@/lib/utils";

export type ProgressProps = {
  /** 0–100. Leave undefined for an indeterminate sweep. */
  value?: number;
  label?: string;
  showValue?: boolean;
  className?: string;
};

export function Progress({ value, label, showValue = false, className }: ProgressProps) {
  const reduce = useReducedMotion();
  const indeterminate = value === undefined;
  const clamped = Math.min(100, Math.max(0, value ?? 0));
  const spring = useSpring(clamped, { stiffness: 140, damping: 24 });
  React.useEffect(() => {
    if (reduce) spring.jump(clamped);
    else spring.set(clamped);
  }, [clamped, reduce, spring]);
  const width = useTransform(spring, (v) => `${v}%`);
  const id = React.useId();

  return (
    <div className={cn("grid w-full gap-2", className)}>
      {(label || showValue) && (
        <div className="flex items-baseline justify-between text-sm">
          <span id={id} className="text-muted-foreground">
            {label}
          </span>
          {showValue && !indeterminate && <span className="font-mono tabular-nums">{Math.round(clamped)}%</span>}
        </div>
      )}
      <div
        role="progressbar"
        aria-labelledby={label ? id : undefined}
        aria-label={label ? undefined : "Progress"}
        aria-valuemin={0}
        aria-valuemax={100}
        aria-valuenow={indeterminate ? undefined : Math.round(clamped)}
        className="relative h-2 w-full overflow-hidden rounded-full bg-muted"
      >
        {indeterminate ? (
          <motion.div
            className="absolute inset-y-0 w-2/5 rounded-full bg-foreground"
            animate={reduce ? { opacity: [0.4, 1, 0.4] } : { x: ["-110%", "260%"] }}
            transition={{ duration: reduce ? 1.6 : 1.3, repeat: Infinity, ease: [0.65, 0, 0.35, 1] }}
          />
        ) : (
          <motion.div className="absolute inset-y-0 left-0 rounded-full bg-foreground" style={{ width }}>
            <span className="absolute inset-y-0 right-0 w-6 rounded-full bg-gradient-to-r from-transparent to-ember/70" />
          </motion.div>
        )}
      </div>
    </div>
  );
}

export type ProgressRingProps = {
  value: number;
  size?: number;
  stroke?: number;
  label?: string;
  className?: string;
};

export function ProgressRing({ value, size = 48, stroke = 4, label = "Progress", className }: ProgressRingProps) {
  const reduce = useReducedMotion();
  const clamped = Math.min(100, Math.max(0, value));
  const spring = useSpring(clamped / 100, { stiffness: 140, damping: 24 });
  React.useEffect(() => {
    if (reduce) spring.jump(clamped / 100);
    else spring.set(clamped / 100);
  }, [clamped, reduce, spring]);
  const r = (size - stroke) / 2;

  return (
    <div
      role="progressbar"
      aria-label={label}
      aria-valuemin={0}
      aria-valuemax={100}
      aria-valuenow={Math.round(clamped)}
      className={cn("relative inline-grid 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={r} fill="none" strokeWidth={stroke} className="stroke-muted" />
        <motion.circle
          cx={size / 2}
          cy={size / 2}
          r={r}
          fill="none"
          strokeWidth={stroke}
          strokeLinecap="round"
          className="stroke-foreground"
          style={{ pathLength: spring }}
        />
      </svg>
      <span className="absolute font-mono text-[11px] font-medium tabular-nums" aria-hidden>
        {Math.round(clamped)}
      </span>
    </div>
  );
}
```