Progress
A determinate progress indicator for known-completion tasks. Linear and circular variants.
import { Progress } from '@/components/progress';
export default function ProgressExample() {
return (
<div className="w-64">
<Progress value={60} />
</div>
);
} Source Code
import { cn, cva } from '@/lib/utils/classnames';
type ProgressSize = 'xs' | 'sm' | 'md' | 'lg';
type ProgressVariant = 'linear' | 'circular';
const linearTrackStyle = cva({
base: [
'relative w-full overflow-hidden rounded-full',
'bg-foreground/10 text-accent',
],
variants: {
size: {
xs: 'h-0.5',
sm: 'h-1',
md: 'h-1.5',
lg: 'h-2',
} satisfies Record<ProgressSize, string>,
},
});
const linearFillStyle =
'h-full rounded-full bg-current transition-[width] duration-150 ease-out';
const circularSizes = {
xs: { px: 12, stroke: 1.5 },
sm: { px: 16, stroke: 2 },
md: { px: 20, stroke: 2 },
lg: { px: 24, stroke: 2.5 },
} satisfies Record<ProgressSize, { px: number; stroke: number }>;
interface ProgressProps
extends Omit<React.ComponentPropsWithRef<'div'>, 'children'> {
/** Current value. Clamped to `[0, max]`. */
value: number;
/** Maximum value. Defaults to 100. */
max?: number;
size?: ProgressSize;
variant?: ProgressVariant;
}
const Progress = ({
value,
max = 100,
size = 'md',
variant = 'linear',
className,
...props
}: ProgressProps) => {
const clamped = Math.max(0, Math.min(max, value));
const percent = (clamped / max) * 100;
const a11yProps = {
role: 'progressbar' as const,
'aria-valuemin': 0,
'aria-valuemax': max,
'aria-valuenow': clamped,
};
if (variant === 'circular') {
const { px, stroke } = circularSizes[size];
const r = (px - stroke) / 2;
const c = 2 * Math.PI * r;
const offset = c * (1 - percent / 100);
return (
<div
className={cn('inline-flex text-accent', className)}
{...a11yProps}
{...props}
>
<svg
width={px}
height={px}
viewBox={`0 0 ${px} ${px}`}
aria-hidden="true"
>
<circle
cx={px / 2}
cy={px / 2}
r={r}
fill="none"
strokeWidth={stroke}
className="stroke-foreground/10"
/>
<circle
cx={px / 2}
cy={px / 2}
r={r}
fill="none"
strokeWidth={stroke}
strokeDasharray={c}
strokeDashoffset={offset}
strokeLinecap="round"
transform={`rotate(-90 ${px / 2} ${px / 2})`}
className="stroke-current transition-[stroke-dashoffset] duration-150 ease-out"
/>
</svg>
</div>
);
}
return (
<div
className={linearTrackStyle({ size, className })}
{...a11yProps}
{...props}
>
<div
className={linearFillStyle}
style={{ width: `${percent}%` }}
aria-hidden="true"
/>
</div>
);
};
export type { ProgressProps };
export { Progress }; API Reference
Extends the div element.
| Prop | Default | Type | Description |
|---|---|---|---|
value | - | number | The current value. Clamped to `[0, max]`. |
max | 100 | number | The maximum value. Defaults to 100. |
variant | "linear" | "linear""circular" | |
size | "md" | "xs""sm""md""lg" |
Variants
linear
circular
import { Progress } from '@/components/progress';
export default function ProgressVariantsExample() {
return (
<div className="flex w-64 flex-col items-center gap-8">
<div className="flex w-full flex-col items-center gap-2">
<Progress value={70} />
<span className="text-foreground-secondary text-xs">linear</span>
</div>
<div className="flex flex-col items-center gap-2">
<Progress value={70} variant="circular" />
<span className="text-foreground-secondary text-xs">circular</span>
</div>
</div>
);
} Sizes
import { Progress } from '@/components/progress';
const sizes = ['xs', 'sm', 'md', 'lg'] as const;
export default function ProgressSizesExample() {
return (
<div className="grid grid-cols-[1fr_auto] items-center gap-x-8 gap-y-4">
{sizes.map((size) => (
<div key={size} className="contents">
<div className="w-64">
<Progress value={60} size={size} />
</div>
<Progress value={60} size={size} variant="circular" />
</div>
))}
</div>
);
} Color
The fill color follows currentColor. Set a text color utility on the root to retint:
<Progress value={50} className="text-error" />
This makes things like an error state in a file upload trivial — no extra prop needed.
Best Practices
- Use Progress when you can report progress. When the task length is known and reportable (uploads, multi-step workflows, downloads), Progress communicates more than a Spinner. When all you can say is “something is happening”, reach for Spinner instead — Progress is the determinate primitive, Spinner is the indeterminate one.
- Accessibility:
- Renders
role="progressbar"witharia-valuemin,aria-valuemax, andaria-valuenow. - Provide
aria-label(oraria-labelledby) when the surrounding context doesn’t already label the bar.
- Renders
Previous
Portal
Next
Radio