import { useEffect, useState } from "react" ;
import { ProgressBar } from "@/components/ui/progress-bar" ;
export default function ProgressBarDemo () {
const [ value , setValue ] = useState ( 18 );
useEffect (() => {
const timer = setTimeout (() => setValue ( 68 ), 600 );
return () => clearTimeout (timer);
}, []);
return (
< ProgressBar className = "max-w-xs" label = "Uploading files" value = {value} />
);
}
CLI Manual
$ pnpm dlx shadcn@latest add @desyne/progress-bar
The CLI installs dependencies and any other components this one uses.
import { ProgressBar, ProgressCircle } from "@/components/ui/progress-bar" ;
< ProgressBar label = "Uploading files" value = { 40 } />
< ProgressCircle aria-label = "Syncing" value = { 60 } />
Animation keyframes
The indeterminate bar uses the animate-indeterminate keyframes from the
theme CSS, which the registry adds on install. The circle's indeterminate
state uses Tailwind's built-in animate-spin.
Progress Bar — a task that is running and will finish: uploads, imports, exports, multi-step setup. Use isIndeterminate while the duration is unknown.
Meter — a level within a known range that isn't "progressing": storage used, quota, password strength.
Spinner — a short, unmeasured wait in a small space, such as inside a button or badge.
Skeleton — while the layout of content that is about to appear is known.
< ProgressBar label = "…" > { /* role="progressbar" */ }
{ /* header: Label + value text */ }
{ /* track */ }
{ /* fill (width = percentage) */ }
</ ProgressBar >
< ProgressCircle > { /* role="progressbar" */ }
{ /* svg: track circle + fill circle */ }
{ /* centered value text (md and lg) */ }
</ ProgressCircle >
Part Renders Notes ProgressBar<div role="progressbar">React Aria ProgressBar. Sets aria-valuenow, aria-valuemin, aria-valuemax and aria-valuetext. Label Label (<span>)Rendered when label is set and wired up as the accessible name. Value text <span>The formatted value (or valueLabel). Hidden when showValue={false} or indeterminate. Track <div>Full-width, rounded, tinted with the tone at 15%. Fill <div>Width follows the percentage with a 500ms transition; slides back and forth when indeterminate. ProgressCircle<div role="progressbar">Same semantics; renders an aria-hidden SVG ring and an optional centered value.
md (8px) is the default. Use sm (4px) inside cards, lists and dialogs, and lg (12px) when the bar is the main content of the view.
import { ProgressBar } from "@/components/ui/progress-bar" ;
export default function ProgressBarSizes () {
return (
< div className = "flex w-full max-w-xs flex-col gap-5" >
< ProgressBar label = "Small" size = "sm" value = { 30 } />
< ProgressBar label = "Medium" size = "md" value = { 55 } />
< ProgressBar label = "Large" size = "lg" value = { 80 } />
</ div >
);
}
brand is the default. Switch to success when a task completes, and to warning or danger when it stalls or fails, so the state is visible at a glance.
import { ProgressBar } from "@/components/ui/progress-bar" ;
const bars = [
{ color: "brand" , label: "Syncing contacts" , value: 45 },
{ color: "primary" , label: "Indexing documents" , value: 62 },
{ color: "success" , label: "Backup complete" , value: 100 },
{ color: "warning" , label: "Retrying upload" , value: 38 },
{ color: "danger" , label: "Migration paused" , value: 71 },
] as const ;
export default function ProgressBarColors () {
return (
< div className = "flex w-full max-w-xs flex-col gap-5" >
{bars. map (( bar ) => (
< ProgressBar key = {bar.color} { ... bar} />
))}
</ div >
);
}
Set isIndeterminate when you can't measure progress yet, such as while connecting or analyzing. The value text is hidden and aria-valuenow is removed, so screen readers announce it as busy rather than 0%.
import { ProgressBar } from "@/components/ui/progress-bar" ;
export default function ProgressBarIndeterminate () {
return (
< div className = "flex w-full max-w-xs flex-col gap-5" >
< ProgressBar label = "Connecting to GitHub…" isIndeterminate />
< ProgressBar aria-label = "Loading" size = "sm" isIndeterminate />
</ div >
);
}
valueLabel replaces the percentage with your own text, like "3 of 12 files". It's also used as aria-valuetext, so pass a string. Combine it with maxValue to work in your own units.
Uploading photos 3 of 12 files
Course progress Lesson 7 of 10
import { ProgressBar } from "@/components/ui/progress-bar" ;
export default function ProgressBarValueLabel () {
return (
< div className = "flex w-full max-w-xs flex-col gap-5" >
< ProgressBar
label = "Uploading photos"
value = { 3 }
maxValue = { 12 }
valueLabel = "3 of 12 files"
/>
< ProgressBar
label = "Course progress"
value = { 7 }
maxValue = { 10 }
valueLabel = "Lesson 7 of 10"
color = "success"
/>
</ div >
);
}
minValue and maxValue (0 and 100 by default) set the range. formatOptions takes any Intl.NumberFormatOptions: the default percent style formats the percentage, every other style formats the raw value in the user's locale.
import { ProgressBar } from "@/components/ui/progress-bar" ;
export default function ProgressBarFormat () {
return (
< div className = "flex w-full max-w-xs flex-col gap-5" >
< ProgressBar
label = "Downloading update"
value = { 348 }
maxValue = { 1024 }
formatOptions = {{ style: "unit" , unit: "megabyte" }}
/>
< ProgressBar
label = "Fundraising goal"
value = { 18450 }
maxValue = { 25000 }
formatOptions = {{
style: "currency" ,
currency: "USD" ,
maximumFractionDigits: 0 ,
}}
color = "success"
/>
< ProgressBar
label = "Rendering"
value = { 33.3 }
formatOptions = {{ style: "percent" , maximumFractionDigits: 1 }}
/>
</ div >
);
}
When the label lives elsewhere in your layout, point to it with aria-labelledby (or pass aria-label) and hide the built-in value with showValue={false}.
Set up your workspace 2 of 5 steps
import { useId } from "react" ;
import { ProgressBar } from "@/components/ui/progress-bar" ;
export default function ProgressBarNoLabel () {
const labelId = useId ();
return (
< div className = "w-full max-w-xs rounded-xl border bg-card p-4" >
< div className = "mb-3 flex items-baseline justify-between text-sm" >
< span className = "font-medium" id = {labelId}>
Set up your workspace
</ span >
< span className = "text-muted-foreground text-xs" >2 of 5 steps</ span >
</ div >
< ProgressBar
aria-labelledby = {labelId}
value = { 40 }
showValue = { false }
size = "sm"
/>
</ div >
);
}
Update value as work progresses; the fill animates between values. Change label and color to reflect the final state.
import { RotateCcwIcon } from "lucide-react" ;
import { useEffect, useState } from "react" ;
import { Button } from "@/components/ui/button" ;
import { ProgressBar } from "@/components/ui/progress-bar" ;
export default function ProgressBarLive () {
const [ value , setValue ] = useState ( 0 );
const done = value >= 100 ;
useEffect (() => {
if (done) return ;
const timer = setInterval (
() => setValue (( v ) => Math. min ( 100 , v + Math. random () * 12 )),
400 ,
);
return () => clearInterval (timer);
}, [done]);
return (
< div className = "flex w-full max-w-xs flex-col items-start gap-4" >
< ProgressBar
label = {done ? "Export ready" : "Exporting report…" }
value = {value}
color = {done ? "success" : "brand" }
/>
< Button
size = "sm"
variant = "outline"
isDisabled = { ! done}
onPress = {() => setValue ( 0 )}
>
< RotateCcwIcon /> Run again
</ Button >
</ div >
);
}
ProgressCircle shows the same information in a compact ring: sm (20px) for inline status, md (48px) and lg (80px) for dashboards and cards. It has no built-in label, so aria-label or aria-labelledby is required. The percentage appears in the center at md and lg. It has its own page, Progress Circle , with more sizes and custom center content.
import { ProgressCircle } from "@/components/ui/progress-bar" ;
export default function ProgressCircleDemo () {
return (
< div className = "flex items-center gap-6" >
< ProgressCircle aria-label = "Syncing" size = "sm" value = { 60 } />
< ProgressCircle aria-label = "Test coverage" value = { 82 } color = "success" />
< ProgressCircle aria-label = "Storage used" size = "lg" value = { 64 } />
</ div >
);
}
With isIndeterminate, the ring becomes a spinning quarter arc.
import { ProgressCircle } from "@/components/ui/progress-bar" ;
export default function ProgressCircleIndeterminate () {
return (
< div className = "flex items-center gap-6" >
< ProgressCircle aria-label = "Loading" size = "sm" isIndeterminate />
< ProgressCircle aria-label = "Loading" isIndeterminate />
< ProgressCircle aria-label = "Loading" size = "lg" isIndeterminate />
</ div >
);
}
valueLabel works for circles too; keep it short enough to fit the ring. Pair small circles with a nearby text label.
6/8
Weekly goal
6 of 8 workouts
import { useId } from "react" ;
import { ProgressCircle } from "@/components/ui/progress-bar" ;
export default function ProgressCircleValueLabel () {
const goalId = useId ();
const syncId = useId ();
return (
< div className = "flex flex-wrap items-center gap-8" >
< div className = "flex items-center gap-3" >
< ProgressCircle
aria-labelledby = {goalId}
size = "lg"
value = { 6 }
maxValue = { 8 }
valueLabel = "6/8"
color = "success"
/>
< div >
< p className = "font-medium text-sm" id = {goalId}>
Weekly goal
</ p >
< p className = "text-muted-foreground text-xs" >6 of 8 workouts</ p >
</ div >
</ div >
< div className = "flex items-center gap-3" >
< ProgressCircle
aria-labelledby = {syncId}
size = "sm"
value = { 40 }
color = "info"
/>
< p className = "text-muted-foreground text-sm" id = {syncId}>
Syncing 2 of 5 calendars
</ p >
</ div >
</ div >
);
}
Per-file progress in a list. Each bar has its own aria-label, turns danger on failure, and is replaced by a check icon once the upload finishes.
import {
CircleAlertIcon,
CircleCheckIcon,
FileImageIcon,
FileTextIcon,
FileVideoIcon,
RotateCcwIcon,
XIcon,
} from "lucide-react" ;
import { useEffect, useState } from "react" ;
import { Button } from "@/components/ui/button" ;
import { ProgressBar } from "@/components/ui/progress-bar" ;
type Upload = {
id : string ;
name : string ;
size : number ;
icon : typeof FileTextIcon;
progress : number ;
failAt ?: number ;
status : "uploading" | "done" | "failed" ;
};
const initial : Upload [] = [
{
id: "1" ,
name: "Q3-report.pdf" ,
size: 2.4 ,
icon: FileTextIcon,
progress: 100 ,
status: "done" ,
},
{
id: "2" ,
name: "team-offsite.jpg" ,
size: 5.8 ,
icon: FileImageIcon,
progress: 20 ,
status: "uploading" ,
},
{
id: "3" ,
name: "product-demo.mp4" ,
size: 48.2 ,
icon: FileVideoIcon,
progress: 5 ,
failAt: 64 ,
status: "uploading" ,
},
];
export default function ProgressBarRecipeUploadList () {
const [ uploads , setUploads ] = useState (initial);
const active = uploads. some (( u ) => u.status === "uploading" );
useEffect (() => {
if ( ! active) return ;
const timer = setInterval (() => {
setUploads (( list ) =>
list. map (( u ) => {
if (u.status !== "uploading" ) return u;
const progress = Math. min ( 100 , u.progress + 30 / u.size + 2 );
if (u.failAt && progress >= u.failAt) {
return { ... u, progress: u.failAt, status: "failed" };
}
return {
... u,
progress,
status: progress >= 100 ? "done" : u.status,
};
}),
);
}, 300 );
return () => clearInterval (timer);
}, [active]);
const retry = ( id : string ) =>
setUploads (( list ) =>
list. map (( u ) =>
u.id === id
? { ... u, progress: 0 , failAt: undefined , status: "uploading" }
: u,
),
);
const remove = ( id : string ) =>
setUploads (( list ) => list. filter (( u ) => u.id !== id));
return (
< div className = "w-full max-w-md rounded-xl border bg-card" >
< div className = "border-b px-4 py-3" >
< h3 className = "font-medium text-sm" >Uploads</ h3 >
</ div >
< ul className = "divide-y" >
{uploads. map (( u ) => {
const Icon = u.icon;
return (
< li key = {u.id} className = "flex items-start gap-3 px-4 py-3" >
< Icon className = "mt-0.5 size-5 shrink-0 text-muted-foreground" />
< div className = "flex min-w-0 flex-1 flex-col gap-2" >
< div className = "flex items-center justify-between gap-2 text-sm" >
< span className = "truncate font-medium" >{u.name}</ span >
< span className = "shrink-0 text-muted-foreground text-xs tabular-nums" >
{u.status === "done" && `${ u . size } MB` }
{u.status === "uploading" && `${ Math . round ( u . progress ) }%` }
{u.status === "failed" && (
< span className = "flex items-center gap-1 text-destructive" >
< CircleAlertIcon className = "size-3.5" /> Failed
</ span >
)}
</ span >
</ div >
{u.status !== "done" && (
< ProgressBar
aria-label = { `Uploading ${ u . name }` }
value = {u.progress}
showValue = { false }
size = "sm"
color = {u.status === "failed" ? "danger" : "brand" }
/>
)}
</ div >
{u.status === "done" && (
< CircleCheckIcon className = "mt-0.5 size-4 shrink-0 text-success" />
)}
{u.status === "failed" && (
< Button
size = "icon-xs"
variant = "ghost"
aria-label = { `Retry ${ u . name }` }
onPress = {() => retry (u.id)}
>
< RotateCcwIcon />
</ Button >
)}
{u.status !== "done" && (
< Button
size = "icon-xs"
variant = "ghost"
aria-label = { `Remove ${ u . name }` }
onPress = {() => remove (u.id)}
>
< XIcon />
</ Button >
)}
</ li >
);
})}
</ ul >
</ div >
);
}
Start indeterminate while the file is analyzed, then switch to a determinate bar that counts rows with valueLabel.
customers-2024.csv
1.8 MB · 4,800 rows
Start import
import { FileSpreadsheetIcon } from "lucide-react" ;
import { useEffect, useState } from "react" ;
import { Button } from "@/components/ui/button" ;
import { ProgressBar } from "@/components/ui/progress-bar" ;
const total = 4800 ;
type Phase = "idle" | "analyzing" | "importing" | "done" ;
export default function ProgressBarRecipeImport () {
const [ phase , setPhase ] = useState < Phase >( "idle" );
const [ rows , setRows ] = useState ( 0 );
useEffect (() => {
if (phase === "analyzing" ) {
const timer = setTimeout (() => setPhase ( "importing" ), 1500 );
return () => clearTimeout (timer);
}
if (phase === "importing" ) {
if (rows >= total) {
setPhase ( "done" );
return ;
}
const timer = setTimeout (
() => setRows (( r ) => Math. min (total, r + 320 )),
150 ,
);
return () => clearTimeout (timer);
}
}, [phase, rows]);
const start = () => {
setRows ( 0 );
setPhase ( "analyzing" );
};
return (
< div className = "flex w-full max-w-sm flex-col gap-4 rounded-xl border bg-card p-5" >
< div className = "flex items-center gap-3" >
< div className = "flex size-9 items-center justify-center rounded-lg bg-muted" >
< FileSpreadsheetIcon className = "size-4.5 text-muted-foreground" />
</ div >
< div className = "min-w-0" >
< p className = "truncate font-medium text-sm" >customers-2024.csv</ p >
< p className = "text-muted-foreground text-xs" >
1.8 MB · {total. toLocaleString ()} rows
</ p >
</ div >
</ div >
{phase === "analyzing" && (
< ProgressBar label = "Analyzing columns…" isIndeterminate size = "sm" />
)}
{(phase === "importing" || phase === "done" ) && (
< ProgressBar
label = {phase === "done" ? "Import complete" : "Importing customers" }
value = {rows}
maxValue = {total}
valueLabel = { `${ rows . toLocaleString () } of ${ total . toLocaleString () } rows` }
color = {phase === "done" ? "success" : "brand" }
size = "sm"
/>
)}
< div className = "flex justify-end gap-2" >
{phase === "idle" || phase === "done" ? (
< Button size = "sm" onPress = {start}>
{phase === "done" ? "Import again" : "Start import" }
</ Button >
) : (
< Button size = "sm" variant = "outline" onPress = {() => setPhase ( "idle" )}>
Cancel
</ Button >
)}
</ div >
</ div >
);
}
A large ProgressCircle driven by the number of completed steps, using maxValue so the ring maps directly to the checklist.
import { useState } from "react" ;
import { Checkbox } from "@/components/ui/checkbox" ;
import { ProgressCircle } from "@/components/ui/progress-bar" ;
const steps = [
{ id: "profile" , label: "Complete your profile" },
{ id: "invite" , label: "Invite your team" },
{ id: "connect" , label: "Connect a repository" },
{ id: "deploy" , label: "Ship your first deploy" },
];
export default function ProgressBarRecipeOnboarding () {
const [ done , setDone ] = useState < string []>([ "profile" ]);
const toggle = ( id : string , checked : boolean ) =>
setDone (( d ) => (checked ? [ ... d, id] : d. filter (( x ) => x !== id)));
return (
< div className = "w-full max-w-sm rounded-xl border bg-card p-5" >
< div className = "mb-4 flex items-center gap-4" >
< ProgressCircle
aria-label = "Setup progress"
size = "lg"
value = {done. length }
maxValue = {steps. length }
color = {done. length === steps. length ? "success" : "brand" }
/>
< div >
< h3 className = "font-semibold" >Get started</ h3 >
< p className = "text-muted-foreground text-sm" >
{done. length === steps. length
? "You're all set."
: `${ steps . length - done . length } steps left` }
</ p >
</ div >
</ div >
< div className = "grid gap-3" >
{steps. map (( step ) => (
< Checkbox
key = {step.id}
isSelected = {done. includes (step.id)}
onChange = {( checked ) => toggle (step.id, checked)}
>
{step.label}
</ Checkbox >
))}
</ div >
</ div >
);
}
Renders role="progressbar" with aria-valuenow, aria-valuemin, aria-valuemax and a formatted aria-valuetext (your valueLabel, or the value formatted with formatOptions).
Indeterminate bars omit aria-valuenow and aria-valuetext, so they're announced as busy.
Every progress bar needs an accessible name: label, aria-label or aria-labelledby. ProgressCircle has no label prop, so always pass one of the aria props.
Progress bars are not live regions; screen readers read the value when the user reaches it. Announce completion separately (for example with a toast ) if it matters.
Respects prefers-reduced-motion. The fill jumps to each new value instead of animating. The indeterminate bar stops sliding and becomes a full-width bar that fades gently in and out, so it never looks like a partial value, and the indeterminate circle stops spinning and fades the same way. Screen readers get the same aria-valuenow / aria-valuetext (or no value when indeterminate) in both modes.
Slot Element data-slot="progress-bar"ProgressBar rootdata-slot="progress-circle"ProgressCircle rootdata-slot="label"The visible label
className accepts a function of the render state, which exposes percentage, valueText and isIndeterminate:
< ProgressBar
label = "Upload"
value = {value}
className = {({ percentage }) => (percentage === 100 ? "opacity-60" : "" )}
/>
Indeterminate bars have no aria-valuenow, so you can also target them with [&:not([aria-valuenow])]:….
color sets --tone on the root. The track uses --tone at 15% and the fill uses it at full strength, so a custom color only needs one variable:
< ProgressBar label = "Rendering" value = { 40 } className = "[--tone:var(--color-violet-600)]" />
Also accepts every prop of React Aria's ProgressBar , such as id, style and aria-describedby. The children are rendered by the component and can't be replaced.
Also accepts every prop of React Aria's ProgressBar .
Meter — a level within a range, like storage used.
Spinner — a compact indeterminate indicator.
Skeleton — placeholders while content loads.
Toast — announce when a long task finishes.