Progress
A bar that reports how far along something is. Built on Radix Progress, so the value is announced to assistive tech for free. The label and readout are droppable parts, a chrome variant strips the track, and a duration makes it a self-running countdown.
Installation#
Usage#
Pass value (0 to max, which defaults to 100). That alone renders the bare bar. Anything you pass as children becomes the row above it, so a label and a readout are composed in rather than switched on: there is no showLabel prop, and dropping either part simply removes it.
import { Progress, ProgressLabel, ProgressValue } from "@/components/ui/progress"
export function Example() {
return (
<Progress value={72}>
<ProgressLabel>Importing components</ProgressLabel>
<ProgressValue />
</Progress>
)
}Anatomy#
import { Progress, ProgressLabel, ProgressValue } from "@/components/ui/progress"
<Progress value={…}>
<ProgressLabel />
<ProgressValue />
</Progress>With a label and a readout#
ProgressValue prints a whole percentage by default and reads the value from context, so it never drifts from the bar. Give it a function to phrase the readout yourself: {(value, max) => …} receives the same numbers. It is aria-hidden, because Radix already exposes the value on the bar itself and a screen reader should not hear it twice.
Sizes#
Four heights. xs (4px) is page chrome: the reading or loading bar pinned under a header. sm is the default for a bar sitting in content, md and lg for a bar that is the point of the panel it is in.
Tones#
The fill takes any of the semantic roles, so a bar can report health as well as distance: flip to success when a task lands, destructive when a quota is nearly spent. Every tone is a token, so all four themes track it.
Vertical#
orientation="vertical" stands the bar up for the things that fill from the floor: a tank, a level meter, a quota per volume. The fill rises from the bottom, size sets the bar’s width instead of its height, and the header parts stack centred above it, so ProgressLabel still names the bar. The bar is as tall as the root: give it a height with className (it is 128px otherwise), or let its parent size it, a column flex parent included: h-auto flex-1 takes what the column leaves. The semantics do not change: the root is still a Radix progressbar reading the value, and it carries data-orientation as a styling hook. There is no aria-orientation, since ARIA does not allow it on a progressbar.
For a tank gauge rather than a line, restyle the track through its data-slot: it fills the root’s width and becomes a ringed well, and padding on it insets the fill, which rises as a share of the well’s inner height (no calc() to write). Step the radii down concentrically: a 24px well with an 8px inset holds a 16px fill. The fill takes the track’s corners, so without the inset a low level in a round-cornered track is carved by the track’s own outline; the inset keeps it a clean pill at any height. Here each gauge is a flex item of a column, taking whatever height the column leaves it. The fill and the indeterminate sweep work the same at any width.
On a muted ground#
The track is a relative tint of the foreground rather than the opaque muted token, so it always sits one step off whatever is behind it: on a card it reads as the muted groove it always was, and on a muted ground (a selected table row, a muted panel) it still shows, where a muted groove would vanish into the row. Nothing to set: it is how the track paints.
A running task#
Between discrete steps the fill glides rather than snapping, so a jump from 24% to 36% reads as movement. Start the upload to watch it climb and land on tone="success".
Indeterminate#
Pass value={null} when there is no percentage to report. The fill becomes a short pill sweeping the track, which says "working" without inventing a number, and the readout falls back to a dash. Radix drops the value from the accessibility tree too, so the bar is announced as indeterminate rather than as zero.
Timed#
Pass duration (in ms) instead of a value and the bar runs itself: the countdown on an alert that closes on its own, a cooldown, the fill of a Hold Button. onComplete fires when the time is up. The fill is the full bar slid along the track on transform, a CSS animation on the compositor, so nothing re-renders and nothing is laid out while it runs, and the leading end keeps its round cap all the way. paused freezes it where it stands and resuming carries on with the time that was left; a new runKey starts it again from the top. Here the alert closes after 8 seconds unless the pointer or focus is on it.
direction="drain" (the default) empties a full bar, the time running out; "fill" fills an empty one, the time building up. Changing duration or direction restarts the run as well. A timed bar reports no value to assistive tech, because a number that moves without re-rendering would be stale the moment it was read: give it a name and, where the time matters, say it in words beside the bar. Under reduced motion it ticks about once a second instead of gliding. The root carries data-timed, data-paused while paused and data-complete once it has run out.
Reading progress#
useScrollProgress() returns how far the document has been scrolled, 0 to 100. Feed it to a chrome bar and you get the reading indicator that rides under a navbar: no track, square ends, and transition="none" so the fill is welded to the scrollbar. A transition here would trail the page by 300ms and read as lag, not polish. Pass it a ref to follow a scroll container instead (a reader pane, a modal body), which is what the panel below does. The blog post page ships the document version.
API reference#
Progress
The bar, role="progressbar", over Radix Progress.Root; forwards its props but asChild. Children become the row above the track. Every part accepts className, merged last.
| Prop | Type | Default | Description |
|---|---|---|---|
| value | number | null | 0 | 0 to max, clamped. null is indeterminate: the fill sweeps. |
| max | number | 100 | The top of the scale, so value={3} max={8} is a step counter. |
| size | "xs" | "sm" | "md" | "lg" | "sm" | Track thickness: 4, 6, 8 or 12px (its width when vertical). |
| tone | "brand" | "foreground" | "success" | "warning" | "destructive" | "info" | "foreground" | The fill's colour. |
| variant | "default" | "chrome" | "default" | chrome drops the track and squares the ends, for a full-bleed reading or loading bar. |
| transition | "smooth" | "none" | "smooth" | smooth glides between steps; none welds the fill to a per-frame value like scroll position. |
| orientation | "horizontal" | "vertical" | "horizontal" | vertical fills from the floor; give the root a height (128px by default). |
| aria-label | string | - | Names the bar when there is no ProgressLabel. |
| duration | number | - | Timed mode, in ms: the bar runs itself over this long and value is ignored. |
| direction | "drain" | "fill" | "drain" | Timed mode: empties from full, or fills from empty. |
| paused | boolean | false | Timed mode: holds the bar where it is. |
| runKey | React.Key | - | Timed mode: change it to restart the run. |
| onComplete | () => void | - | Timed mode: called when the run reaches its end. |
| Data attribute | Description |
|---|---|
| data-state | loading, complete, or indeterminate while value is null (set by Radix). |
| data-value | The clamped value (set by Radix). |
| data-max | The max (set by Radix). |
| data-orientation | horizontal or vertical. |
| data-timed / data-paused / data-complete | Present in timed mode, while paused, and once the run has ended. |
| CSS variable | Description |
|---|---|
| --progress-track | The track's ink: a 4% foreground tint (6% in dark), so it reads on any ground. Override per instance. |
ProgressLabel
What is progressing. Names the bar through aria-labelledby unless the root has its own label. Renders a <span>.
ProgressValue
The readout, a whole percentage by default, aria-hidden since the bar already announces it. Renders a <span>.
| Prop | Type | Default | Description |
|---|---|---|---|
| children | (value: number | null, max: number) => React.ReactNode | - | Phrases the readout yourself, e.g. 3 of 8. value is null while indeterminate. |
useScrollProgress
useScrollProgress(ref?) returns how far the document (or the referenced container) is scrolled, 0 to 100. Pair it with transition="none".