Progress
betaA bar that shows how much of a task is done, with a label, an optional value, tones, sizes, and a state for when no one knows yet.
Preview
tone
size
states
format
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. Add the component.
npx shadcn@latest add @txbrendan/progress
Use it for
- A task that takes more than a second or two and has a known end, like an upload or an import.
- Pass value={null} while the amount is not known yet. The bar pulses until you pass a number.
- Give it a label that says what is happening, like 'Uploading 3 photos'. Add showValue to show the percent.
- Use tone='success' or tone='danger' only to match what the task means, like a quota that is nearly full.
Avoid it for
- A value that is not a task, like disk space used or a password's strength. That needs role="meter", which Progress does not set.
- Short waits under a second. Show nothing, or a spinner in the button that started it.
- Content that is still loading on the page. Use a skeleton in its place.
- Steps in a form. Use step dots or a list of steps.
Props
- value
Required. How much is done, from min to max. null shows the pulsing state for an unknown amount.
number | null
- label
Visible text above the bar. Without it, TypeScript requires aria-label or aria-labelledby.
ReactNode
- showValue
Shows the value as text at the end of the label row. It shows nothing while value is null.
boolean · default false
- tone
Color of the filled part.
"neutral" | "brand" | "danger" | "success" | "warning" · default "neutral"
- size
Bar height: 4, 8, or 12px at the default density. lg also uses larger label text.
"sm" | "md" | "lg" · default "md"
- min
The value at the empty end.
number · default 0
- max
The value at the full end. At max, the root gets data-complete.
number · default 100
- format
How the value reads, as text and to screen readers. The default is a percent of the range.
Intl.NumberFormatOptions
- getAriaValueText
Custom words for screen readers, like '3 of 10 files'.
(formattedValue: string | null, value: number | null) => string
- texture
dither draws the filled part as dots, gradient fades it from faint at the start to full at the moving end, and grain speckles it. Hover fills gradient and grain in. A bar with no value keeps its stripes. Left out, the bar follows the Texture around it.
"flat" | "dither" | "gradient" | "grain"
- className
Classes for the root. Later classes win, so className='max-w-xs' narrows it.
string
| Prop | Type | Default | What it does |
|---|---|---|---|
| value | number | null | - | Required. How much is done, from min to max. null shows the pulsing state for an unknown amount. |
| label | ReactNode | - | Visible text above the bar. Without it, TypeScript requires aria-label or aria-labelledby. |
| showValue | boolean | false | Shows the value as text at the end of the label row. It shows nothing while value is null. |
| tone | "neutral" | "brand" | "danger" | "success" | "warning" | "neutral" | Color of the filled part. |
| size | "sm" | "md" | "lg" | "md" | Bar height: 4, 8, or 12px at the default density. lg also uses larger label text. |
| min | number | 0 | The value at the empty end. |
| max | number | 100 | The value at the full end. At max, the root gets data-complete. |
| format | Intl.NumberFormatOptions | - | How the value reads, as text and to screen readers. The default is a percent of the range. |
| getAriaValueText | (formattedValue: string | null, value: number | null) => string | - | Custom words for screen readers, like '3 of 10 files'. |
| texture | "flat" | "dither" | "gradient" | "grain" | - | dither draws the filled part as dots, gradient fades it from faint at the start to full at the moving end, and grain speckles it. Hover fills gradient and grain in. A bar with no value keeps its stripes. Left out, the bar follows the Texture around it. |
| className | string | - | Classes for the root. Later classes win, so className='max-w-xs' narrows it. |
Accessibility
- Renders role="progressbar" with aria-valuenow, aria-valuemin, aria-valuemax, and aria-valuetext.
- With value={null}, aria-valuenow is left out, which tells screen readers the amount is unknown.
- TypeScript requires a visible label, aria-label, or aria-labelledby, so the bar always has a name.
- In server HTML, only aria-label and aria-labelledby name the bar. The visible label is linked once JavaScript runs.
- Screen readers do not announce each change. Say when the task ends, for example with a message in an aria-live region.
- The fill uses the tone's color on the muted track. No contrast pair checks the fill, so the label and value carry the meaning.
- The fill slides with --motion-duration-slow. The unknown state pulses at --motion-pulse-duration, which reduced motion and motion off set to 0s. Its pale fill, not the solid one, still shows it is not done.
Examples
Upload with a label and percent
<Progress value={40} label="Uploading 3 photos" showValue tone="brand" /><Progress texture="dither" value={40} label="Uploading 3 photos" showValue tone="brand" /><Progress texture="gradient" value={40} label="Uploading 3 photos" showValue tone="brand" /><Progress texture="grain" value={40} label="Uploading 3 photos" showValue tone="brand" />Amount not known yet
<Progress value={null} label="Preparing your export" /><Progress texture="dither" value={null} label="Preparing your export" /><Progress texture="gradient" value={null} label="Preparing your export" /><Progress texture="grain" value={null} label="Preparing your export" />No visible label
<Progress value={72} aria-label="Course progress" size="sm" /><Progress texture="dither" value={72} aria-label="Course progress" size="sm" /><Progress texture="gradient" value={72} aria-label="Course progress" size="sm" /><Progress texture="grain" value={72} aria-label="Course progress" size="sm" />Counting items instead of percent
<Progress
value={3}
max={10}
label="Importing contacts"
showValue
format={{ style: "decimal" }}
/><Progress
texture="dither"
value={3}
max={10}
label="Importing contacts"
showValue
format={{ style: "decimal" }}
/><Progress
texture="gradient"
value={3}
max={10}
label="Importing contacts"
showValue
format={{ style: "decimal" }}
/><Progress
texture="grain"
value={3}
max={10}
label="Importing contacts"
showValue
format={{ style: "decimal" }}
/>Driven by state
"use client";
import { useEffect, useState } from "react";
function Sync() {
const [done, setDone] = useState(0);
useEffect(() => {
if (done >= 100) return;
const timer = setTimeout(() => setDone((value) => Math.min(100, value + 10)), 400);
return () => clearTimeout(timer);
}, [done]);
return <Progress value={done} label={done < 100 ? "Syncing" : "Synced"} showValue tone={done < 100 ? "brand" : "success"} />;
}"use client";
import { useEffect, useState } from "react";
function Sync() {
const [done, setDone] = useState(0);
useEffect(() => {
if (done >= 100) return;
const timer = setTimeout(() => setDone((value) => Math.min(100, value + 10)), 400);
return () => clearTimeout(timer);
}, [done]);
return <Progress texture="dither" value={done} label={done < 100 ? "Syncing" : "Synced"} showValue tone={done < 100 ? "brand" : "success"} />;
}"use client";
import { useEffect, useState } from "react";
function Sync() {
const [done, setDone] = useState(0);
useEffect(() => {
if (done >= 100) return;
const timer = setTimeout(() => setDone((value) => Math.min(100, value + 10)), 400);
return () => clearTimeout(timer);
}, [done]);
return <Progress texture="gradient" value={done} label={done < 100 ? "Syncing" : "Synced"} showValue tone={done < 100 ? "brand" : "success"} />;
}"use client";
import { useEffect, useState } from "react";
function Sync() {
const [done, setDone] = useState(0);
useEffect(() => {
if (done >= 100) return;
const timer = setTimeout(() => setDone((value) => Math.min(100, value + 10)), 400);
return () => clearTimeout(timer);
}, [done]);
return <Progress texture="grain" value={done} label={done < 100 ? "Syncing" : "Synced"} showValue tone={done < 100 ? "brand" : "success"} />;
}A gradient bar, full at the moving end
<Progress value={64} tone="brand" label="Uploading" showValue texture="gradient" />