Progress bar
A determinate or indeterminate bar with the real progressbar role.
npx shadcn@latest add https://ui.goodtabs.co/r/progress-bar.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/progress-bar
Uploading
A label, a mono percentage and a 4px track.
Indeterminate
Building the registry
No value, so a segment sweeps across. Still under reduced motion.
Stacked steps
Typecheck
Lint
Screenshots
Deploying
Several bars in a column, one with its label hidden.
Usage
import { ProgressBar } from "@/components/ui/progress-bar"
<ProgressBar label="Uploading" value={64} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | Accessible name and visible label. | |
| value | number | Current value. Leave out for indeterminate. | |
| max | number | 100 | The value that means done. |
| hideLabel | boolean | false | Keep the label for screen readers only. |
Source
progress-bar/progress-bar.tsx
"use client";
import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
type ProgressBarProps = Omit<React.ComponentProps<"div">, "children" | "role"> & {
/** Accessible name, and the visible label unless `hideLabel` is set. */
label: string;
/** Current value. Leave undefined for an indeterminate bar. */
value?: number;
/** The value that means done. */
max?: number;
/** Hide the visible label and keep it for screen readers. */
hideLabel?: boolean;
};
/**
* A progress bar with the real progressbar role. With a value it is determinate and
* shows the percentage in mono. Without one it sweeps a segment across the track, and
* holds still under reduced motion.
*/
export function ProgressBar({ label, value, max = 100, hideLabel, className, ...props }: ProgressBarProps) {
const reduce = useReducedMotion();
const determinate = value !== undefined;
const clamped = determinate ? Math.min(Math.max(value, 0), max) : 0;
const percent = determinate && max > 0 ? (clamped / max) * 100 : 0;
return (
<div className={cn("flex w-full flex-col gap-1.5", className)} {...props}>
<div className="flex items-baseline justify-between gap-3">
<p className={cn("m-0 truncate text-sm text-foreground", hideLabel && "sr-only")}>{label}</p>
{determinate && (
<p aria-hidden="true" className="m-0 font-mono text-xs text-muted-foreground tabular-nums">
{Math.round(percent)}%
</p>
)}
</div>
<div
role="progressbar"
aria-label={label}
aria-valuemin={determinate ? 0 : undefined}
aria-valuemax={determinate ? max : undefined}
aria-valuenow={determinate ? clamped : undefined}
aria-valuetext={determinate ? `${Math.round(percent)}%` : "Loading"}
className="relative h-1 w-full overflow-hidden rounded-full bg-muted"
>
{determinate ? (
<div
data-slot="progress-bar-fill"
className="h-full rounded-full bg-foreground transition-[width] duration-200 ease-out motion-reduce:transition-none"
style={{ width: `${percent}%` }}
/>
) : (
<motion.div
data-slot="progress-bar-sweep"
className="absolute inset-y-0 left-0 w-2/5 rounded-full bg-foreground"
initial={{ x: reduce ? "75%" : "-100%" }}
animate={reduce ? undefined : { x: ["-100%", "250%"] }}
transition={{ duration: 1.4, ease: [0.4, 0, 0.2, 1], repeat: Infinity }}
/>
)}
</div>
</div>
);
}
For AI agents
Hand your agent this page, the markdown at /components/progress-bar/prompt.md, or the Copy AI prompt button above.
When to use
- Uploads, builds and imports with a known amount of work
- Work that is running but has no measurable end, using the indeterminate form
When not to use
- A goal with a count and a footnote: use progress-card
- A reading against a range: use meter, since it is not progress
Agent notes
- It is a client component because the indeterminate sweep uses motion
- Omit aria-valuenow when value is undefined; the component does this for you
- The sweep animates transform only and holds a static segment under reduced motion
- Value is clamped to 0 and max; the fill width transition is 200ms