Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground
Uploading build artefacts38%

Every kind

Linear, circular, spinner and meter. The first three describe a process; the meter describes a state.

Linear determinateThe default for any measurable job
Uploading64%
Linear indeterminateRunning, duration unknown
Connecting
CircularCompact, inside a card or a button
64
MeterA state, not a process
Storage72%
Seats94%

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.

✓Build
2Test
3Deploy
4Verify

Perception thresholds

Which indicator to use is a function of duration, not of taste.

< 100msNothingReads as instant. An indicator would flash and look like a glitch.
100ms – 1sInline spinner, after a 200ms delayNoticeable but the user stays focused.
1s – 10sDeterminate bar, or a skeletonAttention drifts. Show how much is left.
> 10sPersistent surface, let them leavePeople 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.

0%
38%
100%
Indeterminate
ErrorStops where it failed
64
Ring
Ring done
Spinner
Meter safe
Meter warning
Meter full
xs barTop of a container

Anatomy

Every part, every measurement, and the reason it is that number.

Uploading build artefacts64%

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.

  1. 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.

  2. Track colour--ds-layer-active

    An alpha layer, so the same track works on a card, a dialog, or a coloured banner.

  3. 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.

  4. Percentage12px, tabular

    Tabular figures so the number does not shift the label as it counts up.

  5. 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.

  6. RadiusFully rounded

    Both the track and the fill. A square fill inside a rounded track leaves a visible notch at low percentages.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
track heightxs / sm / md
label gapLabel row to track

Radius

TokenValueUsed for
full—Track and fill

Motion

TokenValueUsed for
width transitionDeterminate fill
indeterminateUnknown-duration sweep
spinSpinner. Linear because it is mechanical.

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightRadiusMax widthWhen to use
Extra small2pxfull100%Pinned to the top edge of a table, card or page during a refresh.
Small4pxfull480pxInline within a list row or a compact card.
Medium6pxfull480pxStandalone, with a label and a percentage.
Ring28–48px——Compact circular. Inside buttons, avatars, or a stat tile.
Spinner14 / 16 / 20 / 24px——Indeterminate only. Match the icon size of its context.
Meter6px—320pxA state such as storage or quota, not a running process.

Do

Uploading64%
128 MB of 200 MB · ~40s left
Give a concrete unit as well as a percentage"64%" is abstract. "128 MB of 200 MB, about 40 seconds remaining" lets the user decide whether to wait or go and do something else.
const t = setTimeout(() => setBusy(true), 200)
return () => clearTimeout(t)
Delay the indicator by about 200msMost requests finish faster than that. Showing a spinner immediately means a flash on every fast response, which reads as instability rather than speed.
Complete100%
Let the bar finish before it disappearsA bar that vanishes at 80% because the request returned leaves the user unsure whether it completed. Animate to 100%, hold briefly, then remove.
Storage used72%
Seats94%
Use a meter for a state, a bar for a processStorage at 72% is not loading. role="meter" tells assistive tech it is a measurement, not something that will finish on its own.

Don't

Almost done…92%
…for the last four minutes
Do not fake the percentageA bar that sprints to 90% and stalls makes a promise and breaks it. Users learn to distrust every progress bar in the product after one experience of this.
Uploading 200 MB…
Do not use a spinner for a measurable jobIf you know the byte count, show it. A spinner for a two-minute upload tells the user nothing except that the page has not crashed.

Please do not close this window (4 min remaining)

Do not block the page for a long jobPast about ten seconds people switch tabs. A modal progress bar means they come back to a stale page instead of a finished one.
<div role="progressbar" aria-valuenow="0">
Do not report 0% for indeterminate progressaria-valuenow="0" tells a screen-reader user that nothing has happened. Omitting it entirely says "running, duration unknown", which is the truth.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

4.1.2Name, Role, ValueA4.1.3Status MessagesAA2.2.1Timing AdjustableA1.4.11Non-text ContrastAA

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

TabProgress indicators are not focusable. Any cancel button next to one is.
EscShould 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.
AttributeApplied toNotes
role="progressbar"The trackFor a process that will finish.
role="meter"A usage barFor a measurement that is simply true right now.
aria-valuenowDeterminate onlyOmit entirely when indeterminate. Never send 0 as a stand-in.
aria-valuemin / -valuemaxThe track0 and 100 unless you are reporting raw units.
aria-label / aria-labelledbyThe trackName what is progressing: "Uploading build artefacts", not "Progress".
aria-live="polite"A status regionAnnounce milestones — 25, 50, 75, 100 — not every percent.
aria-busyThe affected regionOn the container being loaded, so assistive tech knows its contents are provisional.

Code

Example usage

tsx
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

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

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

PropTypeDefaultDescription
valuenumber—0–100. Omit when indeterminate.
indeterminatebooleanfalseDrops aria-valuenow entirely rather than reporting 0.
labelstring—Names what is progressing. Also becomes the accessible name.
showValuebooleanfalseRight-aligned percentage in tabular figures.
toneTone'accent'success on completion, danger on failure.
size'xs' | 'sm' | 'md''md'2 / 4 / 6px track.

ProgressRing

PropTypeDefaultDescription
valuenumber—0–100.
sizenumber40Outer diameter in px.
thicknessnumber3Stroke width.
childrenReactNode—Centred content — usually the number.

Meter

PropTypeDefaultDescription
value*number—Current amount.
maxnumber100Upper bound.
labelstring—Shown above with the percentage.

Notes

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.