Progress
A thin bar for how far along a task is, with an optional label and value.
Vibra's indicator transitions its width alone, on --duration-base and the standard ease, where shadcn's transitions all; the fill is the accent through --primary on a 4px muted track. Put ProgressLabel and ProgressValue inside it to name it and print the percent, or name it with aria-label or aria-labelledby when its name is elsewhere, such as a table row's header. A bar whose size is not known yet takes value={null}: where shadcn draws an empty track, which reads as 0%, Vibra draws a segment two-fifths of the track wide that slides the reading way, and holds it still in the middle of the track — where it cannot be read as an amount — for a reader who has asked for less motion, by the OS setting or a [data-motion="reduced"] above. The slide's keyframes come with the component, hoisted into the head once. Colour a bar by its state with a class on the indicator, and say the state in words beside it.
Install
npx shadcn@latest add @vibra/progressNeeds the @vibra registry in your components.json — set it up once.
Examples
import { Progress, ProgressLabel, ProgressValue } from "@/components/ui/progress"
export default function ProgressDemo() {
return (
<Progress value={68} className="w-full max-w-sm">
<ProgressLabel>Importing customers</ProgressLabel>
<ProgressValue />
</Progress>
)
}Counted in its own units
The range is the queue of 318 invoices, so the bar prints and announces "142 of 318", with the time left as its description.
"use client"
import * as React from "react"
import { formatNumber } from "@/lib/format"
import { Progress, ProgressLabel, ProgressValue } from "@/components/ui/progress"
const QUEUED = 318
const SENT = 142
const ofQueue = (value: number | null) => `${formatNumber(value ?? 0)} of ${formatNumber(QUEUED)}`
// The range is the queue itself — 318 invoices, not 100 — so the figure on the
// screen is one a reader can check against the queue, and a screen reader hears
// the same words rather than a percent. The time left describes the bar.
export default function ProgressCount() {
const hint = React.useId()
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<Progress
value={SENT}
max={QUEUED}
getAriaValueText={(_, value) => `${ofQueue(value)} sent`}
aria-describedby={hint}
>
<ProgressLabel>Sending September invoices</ProgressLabel>
<ProgressValue>{(_, value) => ofQueue(value)}</ProgressValue>
</Progress>
<p id={hint} className="text-xs text-muted-foreground">
About 4 minutes left. The queue keeps going if you close this page.
</p>
</div>
)
}Indeterminate
value={null} while the size is unknown: a segment slides, and holds still mid-track under either reduced-motion switch.
"use client"
import { Progress, ProgressLabel } from "@/components/ui/progress"
// The export counts its rows before it can say how far along it is, so the bar
// has no value yet (value={null}) and says what it is doing in words. The
// segment slides only while motion is welcome: under the OS setting or a
// [data-motion="reduced"] above, it holds still in the middle of the track,
// where it cannot be read as an amount.
export default function ProgressIndeterminate() {
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<Progress value={null} getAriaValueText={() => "Counting rows"}>
<ProgressLabel>Preparing orders-2026-09.csv</ProgressLabel>
</Progress>
<p className="text-xs text-muted-foreground">Counting rows first. The bar fills once the size is known.</p>
</div>
)
}Coloured by threshold
Amber from 80% of the limit, red past it — and the band is said in words with its own icon, never by colour alone.
"use client"
import { CircleAlertIcon, CircleCheckIcon, TriangleAlertIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatNumber } from "@/lib/format"
import { Progress, ProgressLabel, ProgressValue } from "@/components/ui/progress"
const USAGE = [
{ label: "Seats", used: 24, limit: 25, unit: "seats" },
{ label: "Storage", used: 38.2, limit: 100, unit: "GB" },
{ label: "Workflow runs", used: 10_412, limit: 10_000, unit: "runs" },
]
// The band picks the colour, and the band is also said in words under the bar,
// with an icon of its own: from 80% of the limit the fill turns amber, past the
// limit red. Colour is the second channel here, never the only one.
const BANDS = {
within: { word: "Within the plan", icon: CircleCheckIcon, fill: "", tone: "text-muted-foreground" },
near: { word: "Near the limit", icon: TriangleAlertIcon, fill: "[&_[data-slot=progress-indicator]]:bg-warning", tone: "text-warning" },
over: { word: "Over the limit", icon: CircleAlertIcon, fill: "[&_[data-slot=progress-indicator]]:bg-danger", tone: "text-danger" },
}
function state(used: number, limit: number, unit: string) {
if (used > limit) return { band: BANDS.over, said: `Over the limit by ${formatNumber(used - limit)} ${unit}` }
const band = used / limit >= 0.8 ? BANDS.near : BANDS.within
const left = limit - used
return { band, said: `${band.word}: ${formatNumber(left)} ${left === 1 ? unit.replace(/s$/, "") : unit} left` }
}
export default function ProgressThreshold() {
return (
<div className="flex w-full max-w-sm flex-col gap-3">
{USAGE.map(({ label, used, limit, unit }) => {
const { band, said } = state(used, limit, unit)
return (
<div key={label} className="flex flex-col gap-1.5">
<Progress
value={used}
max={limit}
getAriaValueText={() => `${formatNumber(used)} of ${formatNumber(limit)} ${unit}. ${said}.`}
className={cn("gap-2", band.fill)}
>
<ProgressLabel>{label}</ProgressLabel>
<ProgressValue>{() => `${formatNumber(used)} / ${formatNumber(limit)} ${unit}`}</ProgressValue>
</Progress>
<p className={cn("flex items-center gap-1.5 text-xs", band.tone)}>
<band.icon aria-hidden="true" className="size-3.5 shrink-0" />
{said}
</p>
</div>
)
})}
</div>
)
}With a target
The bar runs to the stretch goal, so it can pass the target, which is a mark across the track named under it.
"use client"
import * as React from "react"
import { formatCurrency } from "@/lib/format"
import { Progress, ProgressLabel, ProgressValue } from "@/components/ui/progress"
const BOOKED = 184_200
const TARGET = 210_000
const STRETCH = 250_000
const money = (value: number) => formatCurrency(value, "USD", { compact: true })
const at = `${(TARGET / STRETCH) * 100}%`
// The bar runs to the stretch goal, so it can pass the target; the target is a
// mark across the track, named in the tick label under it and in what a screen
// reader hears. Both are placed on the inline axis, so a right-to-left page
// keeps them the same distance from the start.
export default function ProgressTarget() {
const hint = React.useId()
return (
<div className="flex w-full max-w-sm flex-col gap-1.5">
<div className="relative">
<Progress
value={BOOKED}
max={STRETCH}
getAriaValueText={() => `${money(BOOKED)} booked. Target ${money(TARGET)}.`}
aria-describedby={hint}
>
<ProgressLabel>New business, Q3</ProgressLabel>
<ProgressValue>{() => money(BOOKED)}</ProgressValue>
</Progress>
<span aria-hidden="true" className="absolute -bottom-1 h-3 w-0.5 rounded-full bg-foreground" style={{ insetInlineStart: at }} />
</div>
<div aria-hidden="true" className="relative h-4 text-xs text-muted-foreground">
<span className="absolute whitespace-nowrap" style={{ insetInlineEnd: `calc(100% - ${at} - 2px)` }}>
Target {money(TARGET)}
</span>
</div>
<p id={hint} className="text-xs text-muted-foreground">
{money(TARGET - BOOKED)} to go with 26 days left in the quarter. The bar runs to the {money(STRETCH)} stretch goal.
</p>
</div>
)
}In steps
Four setup steps as the range, so the bar fills a quarter at a time; the list says each step's state, and the next is current.
"use client"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Progress, ProgressLabel, ProgressValue } from "@/components/ui/progress"
const STEPS = [
{ title: "Add your company details", done: true },
{ title: "Connect a bank account", done: true },
{ title: "Choose where you charge tax", done: false },
{ title: "Send your first invoice", done: false },
]
const DONE = STEPS.filter((step) => step.done).length
const NEXT = STEPS.find((step) => !step.done)
// Setup moves in whole steps, so the bar's range is the four steps and its value
// the steps done: it fills a quarter at a time and says "2 of 4 steps done"
// rather than 50%. The list names every step with its state in words, and the
// next one is the current step.
export default function ProgressSteps() {
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<Progress value={DONE} max={STEPS.length} getAriaValueText={() => `${DONE} of ${STEPS.length} steps done`}>
<ProgressLabel>Set up billing</ProgressLabel>
<ProgressValue>{() => `${DONE} of ${STEPS.length}`}</ProgressValue>
</Progress>
<ol aria-label="Billing setup steps" className="flex flex-col gap-2 text-sm">
{STEPS.map((step) => {
const current = step === NEXT
return (
<li key={step.title} aria-current={current ? "step" : undefined} className="flex items-center gap-2.5">
<span
aria-hidden="true"
className={cn(
"flex size-4 shrink-0 items-center justify-center rounded-full",
step.done ? "bg-primary text-primary-foreground" : current ? "ring-[1.5px] ring-primary ring-inset" : "ring-1 ring-input ring-inset"
)}
>
{step.done ? <CheckIcon className="size-3" /> : null}
</span>
<span className={cn(!step.done && !current && "text-muted-foreground", current && "font-medium")}>{step.title}</span>
<span className="sr-only">{step.done ? ", done" : current ? ", next" : ", not started"}</span>
</li>
)
})}
</ol>
</div>
)
}With a status
An import from upload to checked rows: the bar goes indeterminate while rows are checked, and a status line speaks once per phase.
"use client"
import * as React from "react"
import { CircleCheckIcon, FileSpreadsheetIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatNumber } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress"
const FILE = { name: "ledger-august.xlsx", megabytes: 11.4 }
const ROWS = formatNumber(12_480)
type Phase = "ready" | "uploading" | "checking" | "done"
// One import, told three ways that never disagree: the bar, the line under the
// file name, and one polite status line that speaks when the phase changes —
// not at every percent. Checking has no percent to give, so the bar goes
// indeterminate. Import and Cancel are one button, so the focus stays put.
export default function ProgressUpload() {
const [phase, setPhase] = React.useState<Phase>("ready")
const [sent, setSent] = React.useState(0)
const [said, setSaid] = React.useState("")
const working = phase === "uploading" || phase === "checking"
React.useEffect(() => {
if (!working) return
const timer = window.setTimeout(
() => {
if (phase === "checking") {
setPhase("done")
setSaid(`${ROWS} rows imported from ${FILE.name}.`)
} else if (sent >= 90) {
setSent(100)
setPhase("checking")
setSaid(`Uploaded. Checking ${ROWS} rows.`)
} else {
setSent(sent + 10)
}
},
phase === "checking" ? 1600 : 260
)
return () => window.clearTimeout(timer)
}, [working, phase, sent])
function press() {
setSent(0)
setPhase(working ? "ready" : "uploading")
setSaid(working ? "Import cancelled." : `Uploading ${FILE.name}.`)
}
const line = {
ready: `${FILE.megabytes} MB · ready to import`,
uploading: `${formatNumber((FILE.megabytes * sent) / 100, { maximumFractionDigits: 1 })} of ${FILE.megabytes} MB`,
checking: `Checking ${ROWS} rows…`,
done: `${ROWS} rows imported`,
}[phase]
const Icon = phase === "done" ? CircleCheckIcon : FileSpreadsheetIcon
return (
<div className="flex w-full max-w-sm flex-col gap-3 panel p-4">
<div className="flex items-center gap-3">
<Icon aria-hidden="true" className={cn("size-5 shrink-0", phase === "done" ? "text-success" : "text-muted-foreground")} />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium">{FILE.name}</span>
<span className="text-xs text-muted-foreground tabular-nums">{line}</span>
</div>
<Button size="sm" variant="outline" onClick={press}>
{working ? "Cancel" : phase === "done" ? "Import again" : "Import"}
</Button>
</div>
<Progress
aria-label={`Importing ${FILE.name}`}
value={phase === "checking" ? null : phase === "done" ? 100 : sent}
getAriaValueText={(percent) => (phase === "checking" ? `Checking ${ROWS} rows` : phase === "done" ? "Imported" : percent)}
className={cn(phase === "done" && "[&_[data-slot=progress-indicator]]:bg-success")}
/>
<p role="status" className="sr-only">
{said}
</p>
</div>
)
}Done, or stopped
A finished bar hands over the file; a stopped one keeps the place it reached, turns red and says why, with a Retry that names the file.
"use client"
import * as React from "react"
import { CircleAlertIcon, CircleCheckIcon, DownloadIcon, RotateCwIcon } from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress"
// How a bar ends. A finished one is full, says it is ready and hands over the
// file. A stopped one keeps the place it reached — the reader can see how far
// it got — turns red, and says why in words beside an icon, so the colour is
// never the only sign; Retry names the file it retries.
export default function ProgressOutcomes() {
const id = React.useId()
return (
<ul aria-label="Recent exports" className="flex w-full max-w-sm flex-col gap-4">
<li className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<span id={`${id}-done`} className="truncate text-sm font-medium">invoices-q3.pdf</span>
<a
href="/exports/invoices-q3.pdf"
download
aria-label="Download invoices-q3.pdf"
className={buttonVariants({ variant: "outline", size: "xs" })}
>
<DownloadIcon aria-hidden="true" data-icon="inline-start" />
Download
</a>
</div>
<Progress value={100} aria-labelledby={`${id}-done`} getAriaValueText={() => "Done"} />
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<CircleCheckIcon aria-hidden="true" className="size-3.5 shrink-0 text-success" />
Ready · 2.4 MB, kept for 7 days
</p>
</li>
<li className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<span id={`${id}-stopped`} className="truncate text-sm font-medium">customers-all.csv</span>
<Button variant="outline" size="xs" aria-label="Retry customers-all.csv">
<RotateCwIcon aria-hidden="true" data-icon="inline-start" />
Retry
</Button>
</div>
<Progress
value={71}
aria-labelledby={`${id}-stopped`}
getAriaValueText={() => "Stopped at 71%"}
className="[&_[data-slot=progress-indicator]]:bg-danger"
/>
<p className="flex items-center gap-1.5 text-xs text-danger">
<CircleAlertIcon aria-hidden="true" className="size-3.5 shrink-0" />
Stopped at 71%: the connection to the warehouse dropped
</p>
</li>
</ul>
)
}In a table
One bar per project, labelled by its row header and the column title, with the percent in a fixed width so the bars line up.
import * as React from "react"
import { avatarFor } from "@/lib/avatars"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Progress, ProgressValue } from "@/components/ui/progress"
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
const PROJECTS = [
{ code: "ATL", name: "Atlas migration", lead: "Hassan Ortiz", due: "Dec 4", done: 85 },
{ code: "TRS", name: "Trust centre", lead: "Farid Pereira", due: "Oct 1", done: 62 },
{ code: "BIL", name: "Billing rewrite", lead: "Sonia Keller", due: "Oct 18", done: 50 },
{ code: "EDG", name: "Edge caching", lead: "Greta Eriksen", due: "Dec 24", done: 43 },
]
// A bar per row, named by its row: each progressbar is labelled by the
// project's own header cell and the column title, so a screen reader moving
// through the column hears "Atlas migration Progress, 85%". The percent sits
// after the bar in a fixed width, so the bars line up down the column.
export default function ProgressTable() {
const id = React.useId()
return (
<Table className="max-w-xl">
<TableCaption className="sr-only">Active projects</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead className="max-sm:hidden">Lead</TableHead>
<TableHead className="max-sm:hidden">Due</TableHead>
<TableHead id={`${id}-progress`}>Progress</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{PROJECTS.map((project) => (
<TableRow key={project.code}>
<TableHead scope="row" id={`${id}-${project.code}`} className="font-medium text-foreground">
{project.name}
</TableHead>
<TableCell className="max-sm:hidden">
<span className="flex items-center gap-2">
<Avatar size="sm" aria-hidden="true">
<AvatarImage src={avatarFor(project.lead)} alt="" />
<AvatarFallback>{project.lead.split(" ").map((part) => part[0]).join("")}</AvatarFallback>
</Avatar>
{project.lead}
</span>
</TableCell>
<TableCell className="text-muted-foreground tabular-nums max-sm:hidden">{project.due}</TableCell>
<TableCell>
<Progress
value={project.done}
aria-labelledby={`${id}-${project.code} ${id}-progress`}
className="w-32 flex-nowrap items-center gap-2 sm:w-40"
>
<ProgressValue className="order-last ms-0 w-9 shrink-0 text-end text-xs" />
</Progress>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}Right to left
In Arabic the fill grows from the right, the value is in the page's own digits, and the indeterminate segment slides right to left.
"use client"
import { Progress, ProgressLabel, ProgressValue } from "@/components/ui/progress"
// The bar in Arabic. The fill grows from the right, where the reading starts,
// the value sits at the inline end in the page's own digits (locale), and a bar
// with no value slides the reading way — right to left.
export default function ProgressRtl() {
return (
<div dir="rtl" lang="ar" className="flex w-full max-w-sm flex-col gap-6">
<Progress value={68} locale="ar-EG">
<ProgressLabel>استيراد العملاء</ProgressLabel>
<ProgressValue />
</Progress>
<Progress value={null} getAriaValueText={() => "جارٍ عدّ الصفوف"}>
<ProgressLabel>تجهيز ملف التصدير</ProgressLabel>
</Progress>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | number | null | — | How far along, between min and max; null while the size of the work is not known yet, which draws the sliding segment. |
| min / max | number | 0 / 100 | The range. Count in the task's own units — 318 invoices, four steps — rather than converting to a percent. |
| getAriaValueText | (formattedValue: string, value: number | null) => string | — | What a screen reader hears for the value: the units ("142 of 318 sent") or the state ("Counting rows") rather than a bare percent. |
| locale / format | Intl.LocalesArgument / Intl.NumberFormatOptions | — | How the value is formatted for ProgressValue and the default value text — an Arabic page's own digits, say. |
| ProgressValue.children | (formattedValue: string | null, value: number | null) => React.ReactNode | — | Prints something other than the percent: "142 of 318", "$184.2K". |
Dependencies
Registry
Source
"use client"
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
import { cn } from "@/lib/utils"
function Progress({
className,
children,
value,
...props
}: ProgressPrimitive.Root.Props) {
return (
<ProgressPrimitive.Root
value={value}
data-slot="progress"
className={cn("flex flex-wrap gap-3", className)}
{...props}
>
{children}
<ProgressTrack>
<ProgressIndicator />
</ProgressTrack>
</ProgressPrimitive.Root>
)
}
function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
return (
<ProgressPrimitive.Track
className={cn(
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
className
)}
data-slot="progress-track"
{...props}
/>
)
}
/**
* The slide a bar with no value makes: a segment two-fifths of the track wide,
* centred, travels from wholly before the track's start to wholly past its end.
* The keyframes come with the component — React hoists the sheet into the head
* once, however many bars there are — so nothing is added to your CSS.
*/
const INDETERMINATE_KEYFRAMES =
"@keyframes progress-indeterminate{from{translate:-175% 0}to{translate:175% 0}}"
function ProgressIndicator({
className,
...props
}: ProgressPrimitive.Indicator.Props) {
return (
<>
<style href="vibra-progress-keyframes" precedence="default">
{INDETERMINATE_KEYFRAMES}
</style>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={(state) =>
cn(
"h-full bg-primary transition-[width] duration-(--duration-base) ease-(--ease-standard)",
// No value (`value={null}`): a segment rather than a fill. It rests
// in the middle of the track, where it cannot be read as an amount
// — an amount always starts at the inline start — and slides the
// reading way only while the reader has not asked for less motion,
// by the OS setting or a [data-motion="reduced"] above. A loop
// takes no duration token, so it has to not run instead.
state.status === "indeterminate" &&
"mx-auto w-2/5 motion-safe:not-in-data-[motion=reduced]:animate-[progress-indeterminate_1.5s_ease-in-out_infinite] rtl:[animation-direction:reverse]",
typeof className === "function" ? className(state) : className
)
}
{...props}
/>
</>
)
}
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
return (
<ProgressPrimitive.Label
className={cn("text-sm font-medium", className)}
data-slot="progress-label"
{...props}
/>
)
}
// Pushed to the end of the row on the inline axis, so a right-to-left bar
// prints its value at the far (left) end rather than against the label.
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
return (
<ProgressPrimitive.Value
className={cn(
"ms-auto text-sm text-muted-foreground tabular-nums",
className
)}
data-slot="progress-value"
{...props}
/>
)
}
export {
Progress,
ProgressTrack,
ProgressIndicator,
ProgressLabel,
ProgressValue,
}