Progress Indicator
Determinate whenever you can compute a percentage. A real number turns waiting into progress; a spinner just says "still here".
Also called Spinner, Loader, Progress Bar, Meter, Activity Indicator, Loading — in this system all of them are Progress Indicator.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Every kind
Linear, circular, spinner and meter. The first three describe a process; the meter describes a state.
Step progress
For a wizard, the bar shows position rather than time. Completed steps get a check, the current step is accented, and future steps are muted.
Perception thresholds
Which indicator to use is a function of duration, not of taste.
| < 100ms | Nothing | Reads as instant. An indicator would flash and look like a glitch. |
| 100ms – 1s | Inline spinner, after a 200ms delay | Noticeable but the user stays focused. |
| 1s – 10s | Determinate bar, or a skeleton | Attention drifts. Show how much is left. |
| > 10s | Persistent surface, let them leave | People switch tabs. Do not hold the page hostage. |
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
128 MB of 200 MB · about 40 seconds remaining
Label, percentage, track, fill, and a line of concrete detail. The percentage alone is abstract; "128 MB of 200 MB" is what the user actually understands.
- Track height2 / 4 / 6px
xs at the top edge of a container, sm inline, md as a standalone indicator. Anything thicker starts reading as a chart.
- Track colour--ds-layer-active
An alpha layer, so the same track works on a card, a dialog, or a coloured banner.
- Fill transitionwidth, 420ms standard
Long enough that a jump from 20% to 80% reads as movement rather than a teleport. Width is a layout property, but on a 4px bar the cost is negligible.
- Percentage12px, tabular
Tabular figures so the number does not shift the label as it counts up.
- Concrete detailBelow the bar
"128 MB of 200 MB" is more useful than "64%". Give both — the percentage for the glance, the units for the judgement.
- RadiusFully rounded
Both the track and the fill. A square fill inside a rounded track leaves a visible notch at low percentages.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-layer-active | — | Track |
| --ds-accent | — | Fill, default tone |
| --ds-success | — | Completed fill |
| --ds-danger | — | Failed fill |
| --ds-fg-secondary | — | Label |
| --ds-fg-muted | — | Percentage and detail line |
Spacing
| Token | Value | Used for |
|---|---|---|
| track height | xs / sm / md | |
| label gap | Label row to track |
Radius
| Token | Value | Used for |
|---|---|---|
| full | — | Track and fill |
Motion
| Token | Value | Used for |
|---|---|---|
| width transition | Determinate fill | |
| indeterminate | Unknown-duration sweep | |
| spin | Spinner. Linear because it is mechanical. |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Radius | Max width | When to use |
|---|---|---|---|---|
| Extra small | 2px | full | 100% | Pinned to the top edge of a table, card or page during a refresh. |
| Small | 4px | full | 480px | Inline within a list row or a compact card. |
| Medium | 6px | full | 480px | Standalone, with a label and a percentage. |
| Ring | 28–48px | — | — | Compact circular. Inside buttons, avatars, or a stat tile. |
| Spinner | 14 / 16 / 20 / 24px | — | — | Indeterminate only. Match the icon size of its context. |
| Meter | 6px | — | 320px | A state such as storage or quota, not a running process. |
const t = setTimeout(() => setBusy(true), 200)
return () => clearTimeout(t)Please do not close this window (4 min remaining)
<div role="progressbar" aria-valuenow="0">Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- The fill must reach 3:1 against the track — it is a meaningful graphic under WCAG 1.4.11.
- The track itself does not need to meet contrast against the page, but if it is invisible the user cannot see how much is left.
- Never signal completion with colour alone. Pair the green fill with a check, the word "Complete", or 100%.
Keyboard
| Tab | Progress indicators are not focusable. Any cancel button next to one is. |
| Esc | Should cancel a cancellable operation, if a cancel affordance exists. |
Screen readers
- Announce milestones, not every update. A live region firing on every percent is unusable.
- Say what is progressing. "Progress, 64%" is far less useful than "Uploading build artefacts, 64%".
- On completion, announce the outcome rather than 100%: "Upload complete, 200 MB".
Focus & touch
- Progress never takes focus. If focus was inside a region that is now loading, leave it there — moving it to a spinner strands the user when the spinner disappears.
- Progress bars are not interactive, so touch targets do not apply — but any cancel control beside one must still be 44px, and it must not be so close that a mis-tap cancels a long job.
| Attribute | Applied to | Notes |
|---|---|---|
| role="progressbar" | The track | For a process that will finish. |
| role="meter" | A usage bar | For a measurement that is simply true right now. |
| aria-valuenow | Determinate only | Omit entirely when indeterminate. Never send 0 as a stand-in. |
| aria-valuemin / -valuemax | The track | 0 and 100 unless you are reporting raw units. |
| aria-label / aria-labelledby | The track | Name what is progressing: "Uploading build artefacts", not "Progress". |
| aria-live="polite" | A status region | Announce milestones — 25, 50, 75, 100 — not every percent. |
| aria-busy | The affected region | On the container being loaded, so assistive tech knows its contents are provisional. |
Example usage
1import { Progress, ProgressRing, Spinner } from '@/ui/Feedback'2import { Meter } from '@/ui/Display'34// Determinate, with a concrete unit alongside the percentage5<Progress value={pct} label="Uploading build artefacts" showValue />6<p className="text-caption text-fg-muted">7 {formatBytes(sent)} of {formatBytes(total)} · {eta} remaining8</p>910// Indeterminate: no aria-valuenow at all11<Progress indeterminate label="Connecting to the cluster" />1213// Delay by 200ms so fast responses never flash a spinner14function useDelayedBusy(active: boolean, delay = 200) {15 const [show, setShow] = useState(false)16 useEffect(() => {17 if (!active) return setShow(false)18 const t = setTimeout(() => setShow(true), delay)19 return () => clearTimeout(t)20 }, [active, delay])21 return show22}2324// Announce milestones, not every percent25const milestone = Math.floor(pct / 25) * 2526<div aria-live="polite" className="sr-only">27 {milestone > 0 && milestone + '% uploaded'}28</div>2930// Let it finish before it disappears31async function upload() {32 await send()33 setPct(100)34 await wait(400) // let the bar land35 setDone(true)36}Framework-free HTML
<!-- Determinate -->
<div class="ds-progress">
<div class="ds-progress__header">
<span id="up-label">Uploading build artefacts</span>
<span class="ds-progress__value">64%</span>
</div>
<div class="ds-progress__track"
role="progressbar"
aria-labelledby="up-label"
aria-valuenow="64" aria-valuemin="0" aria-valuemax="100">
<div class="ds-progress__fill" style="width: 64%"></div>
</div>
</div>
<!-- Indeterminate: aria-valuenow is ABSENT, not zero -->
<div class="ds-progress__track" role="progressbar" aria-label="Connecting">
<div class="ds-progress__fill ds-progress__fill--indeterminate"></div>
</div>
<!-- A state, not a process -->
<div role="meter" aria-label="Storage used"
aria-valuenow="72" aria-valuemin="0" aria-valuemax="100">…</div>CSS
.ds-progress__track {
block-size: 6px;
inline-size: 100%;
overflow: hidden;
border-radius: 999px;
background: var(--ds-layer-active);
}
.ds-progress__fill {
block-size: 100%;
border-radius: 999px; /* rounded, or low values show a notch */
background: var(--ds-accent);
transition: width 420ms var(--ease-standard);
}
/* Indeterminate: a sweep, on transform only */
.ds-progress__fill--indeterminate {
inline-size: 100%;
transform-origin: left;
animation: indeterminate 1.4s var(--ease-standard) infinite;
}
@keyframes indeterminate {
0% { transform: translateX(-100%) scaleX(0.35) }
50% { transform: translateX(20%) scaleX(0.60) }
100% { transform: translateX(150%) scaleX(0.35) }
}
/* A spinner is mechanical — linear, not eased */
@keyframes spin { to { transform: rotate(360deg) } }
.ds-spinner { animation: spin 720ms linear infinite; }
/* Reduced motion: slow the loop rather than removing it, so the user
can still tell that something is running. */
@media (prefers-reduced-motion: reduce) {
.ds-progress__fill--indeterminate { animation-duration: 3s; }
.ds-spinner { animation-duration: 2s; }
}Component API
Progress
| Prop | Type | Default | Description |
|---|---|---|---|
| value | number | — | 0–100. Omit when indeterminate. |
| indeterminate | boolean | false | Drops aria-valuenow entirely rather than reporting 0. |
| label | string | — | Names what is progressing. Also becomes the accessible name. |
| showValue | boolean | false | Right-aligned percentage in tabular figures. |
| tone | Tone | 'accent' | success on completion, danger on failure. |
| size | 'xs' | 'sm' | 'md' | 'md' | 2 / 4 / 6px track. |
ProgressRing
| Prop | Type | Default | Description |
|---|---|---|---|
| value | number | — | 0–100. |
| size | number | 40 | Outer diameter in px. |
| thickness | number | 3 | Stroke width. |
| children | ReactNode | — | Centred content — usually the number. |
Meter
| Prop | Type | Default | Description |
|---|---|---|---|
| value* | number | — | Current amount. |
| max | number | 100 | Upper bound. |
| label | string | — | Shown above with the percentage. |
Professional tips
- For multi-file uploads, show one bar for the batch and a count underneath. Twelve individual bars is a progress wall nobody reads.
- If the operation can be cancelled, put the cancel button next to the bar, not in a menu. Users look for it exactly where they are already looking.
- When progress genuinely stalls, say so. "Waiting for the build server" after fifteen seconds of no movement is far better than a bar that has simply stopped.
- Reserve the space for the bar before it appears, or its arrival shifts everything below it and the user loses their place.
Performance
- Do not update the bar more than about twenty times a second. Throttle progress events — a fast upload can otherwise fire hundreds of state updates per second.
- Animate transform for indeterminate sweeps and width only for determinate fills. On a 4px bar the layout cost of width is negligible; on anything larger, use transform: scaleX.
- A page with many simultaneous spinners is many simultaneous animations. Use one indeterminate bar at the top of the region instead.
Common mistakes
- Reporting aria-valuenow="0" for indeterminate progress, which announces as "0 percent" — the opposite of the intended meaning.
- Showing a spinner instantly, so every fast request produces a flash that reads as a glitch.
- Removing the bar the moment the request resolves, leaving the user unsure whether it finished.
- Announcing every percentage change in a live region, which floods the screen-reader queue.
- Using role="progressbar" for a storage meter, so assistive tech tells the user their disk usage is "loading".
Real-world recommendations
- An honest indeterminate bar beats a dishonest determinate one every time. Users forgive not knowing; they do not forgive being misled.
- For jobs over ten seconds, send an email or a notification on completion and let the user leave. Holding the page is a design failure, not a safety measure.
- Progress that includes a time estimate should round generously and never revise upward twice. "About a minute" that becomes "about three minutes" destroys confidence.
- Instrument how long your "fast" operations actually take at p95. Most teams discover that the spinner they thought was rare is showing on a third of requests.