Progress Bar
Non-interactive progress indicator. Communicates completion, health, or utilization through a filled track. Four semantic variants, three sizes, and an indeterminate shimmer mode.
Preview
Default (65%)65%
Success (100%)100%
Warning (80%)80%
Danger (95%)95%
Sizes
Three track heights for different information densities. Use sm inside dense table rows and list items; lg for prominent hero metrics.
sm
4px
md
6px
lg
8px
Indeterminate
When end value is unknown — file upload, background job, AI generation — show a shimmer sweep instead of a static fill. Set aria-busy="true" and omit aria-valuenow.
Tokens
| Token | Dark | Light | Use |
|---|---|---|---|
--progress-track | rgb(40, 42, 48) | rgb(209, 213, 219) | Track background |
--progress-success | #22c55e | #22c55e | Success fill |
--progress-warning | #f59e0b | #f59e0b | Warning fill |
--progress-danger | #ef4444 | #ef4444 | Danger fill |
Implementation
ProgressBar.tsx
tsx
import { cn } from "@/lib";
interface ProgressBarProps {
value?: number;
max?: number;
variant?: "default" | "success" | "warning" | "danger";
size?: "sm" | "md" | "lg";
indeterminate?: boolean;
showValue?: boolean;
label?: string;
className?: string;
}
const fillColors = {
default: "rgb(var(--accent))",
success: "rgb(var(--progress-success))",
warning: "rgb(var(--progress-warning))",
danger: "rgb(var(--progress-danger))",
};
const heights = { sm: 4, md: 6, lg: 8 };
export function ProgressBar({
value = 0,
max = 100,
variant = "default",
size = "md",
indeterminate = false,
showValue = false,
label,
className,
}: ProgressBarProps) {
const pct = Math.min(100, Math.max(0, (value / max) * 100));
const h = heights[size];
return (
<div className={cn("w-full", className)}>
{showValue && (
<div className="flex justify-end mb-1">
<span className="text-[11px] font-medium text-[rgb(var(--text-tertiary))]">
{Math.round(pct)}%
</span>
</div>
)}
<div
role="progressbar"
aria-valuenow={indeterminate ? undefined : value}
aria-valuemin={0}
aria-valuemax={max}
aria-label={label}
aria-busy={indeterminate}
className="relative overflow-hidden rounded-full"
style={{
height: h,
background: "rgb(var(--progress-track))",
boxShadow: "inset 0 1px 0 rgba(0,0,0,0.12)",
}}
>
{/* Specular top-edge highlight */}
<div
className="absolute inset-x-0 top-0 pointer-events-none"
style={{
height: 1,
background: "linear-gradient(90deg, transparent, rgba(255,255,255,0.18), transparent)",
}}
/>
{indeterminate ? (
<div
className="absolute inset-y-0 w-1/3 rounded-full animate-[shimmer_1.4s_ease-in-out_infinite]"
style={{ background: fillColors[variant] }}
/>
) : (
<div
className="h-full rounded-full transition-[width] duration-500 ease-out"
style={{ width: `${pct}%`, background: fillColors[variant] }}
/>
)}
</div>
</div>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
value | number | — | Current progress value between 0 and max. |
max | number | 100 | Maximum value. Percentage is computed as value / max. |
variant | "default" | "success" | "warning" | "danger" | "default" | Semantic fill colour — maps to the corresponding status token. |
size | "sm" | "md" | "lg" | "md" | Track height: sm = 4 px, md = 6 px, lg = 8 px. |
indeterminate | boolean | false | Shows a shimmer sweep animation when the end value is unknown. |
showValue | boolean | false | Renders a percentage label above the right edge of the track. |
label | string | — | Accessible label attached via aria-label. Required for screen readers. |
Accessibility
- →Use role="progressbar" with aria-valuenow, aria-valuemin, and aria-valuemax on determinate bars.
- →For indeterminate bars, set aria-busy="true" and omit aria-valuenow — do not set it to 0.
- →Always provide an aria-label or a visible text label linked with aria-labelledby.
- →Colour alone does not convey variant meaning — pair the variant with a label (e.g., "Budget: 95% — Critical").
- →Progress bars are non-interactive and never receive keyboard focus.