Design System

Design System

Progress

Indicators

Open in Storybook: Progress (opens in a new tab)

On this page

Overview

The native progress tag is used for work that will finish: an upload, an import, a feed loading, a place in a queue. It has four parts: a label that names it, a value (value and max), a fill and a track. Fill and track are pseudo-elements, so the bar needs no wrapper, no inner div will be used for this component.

It is a semantic HTML element and every status, milestones and messages are handled natively. If we need to announce a status we will use ariaNotify().

It is not focusable and not a live region. In case we need to manipulate the progress we will have to have an interactive control associated to it.

Anatomy

  1. Label

    label for

    Names the bar. Always visible. “Progress bar” is not part of the name.

  2. Value text

    span aria-hidden="true"

    The visible copy of the value. Hidden because the bar already exposes it.

  3. Track

    progress, ::-webkit-progress-bar

    The whole scale, 0 to max. The element holds the role, the name, the value and the range.

  4. Fill

    ::-webkit-progress-value, ::-moz-progress-bar

    The part done. With no value it is absent and a slice slides over the track.

  5. Status

    p with an svg

    What the task is doing, in words and a shape. A change of its words is said once.

  6. Control

    button type="button"

    Cancel, Pause or Retry: the keyboard story. Tab skips the bar and lands here.

Usage

Use cases

Functional elements where you can interact with different states, use cases and designs.

  • One file uploading

    Determinate, in bytes.

    68%

    Said with ariaNotify(): nothing yet.

    value="68" max="100"

    Done and failed in a status line. Offer cancel.

  • Several uploads

    One named bar per file.

    3 files

    One progress per file, the label is the file name

    An overall bar needs its own label.

  • Importing contacts

    Unit is not percent.

    Step 3 of 8

    Said with ariaNotify(): nothing yet.

    value="3" max="8" aria-valuetext="Step 3 of 8"

    Same words visible and in aria-valuetext.

  • Position in a queue

    Spots moved, not a percentage.

    28th · ~4 min

    Said with ariaNotify(): nothing yet.

    value="12" max="40" aria-valuetext="28th in line, about 4 minutes"

    Say the position and the wait in text.

  • Loading the feed

    Indeterminate + aria-busy.

    No value · aria-busy on the region

    Remove aria-busy when done.

  • Sync of unknown length

    Indeterminate + text.

    Said with ariaNotify(): nothing yet.

    No value · text says what is happening

    No invented percentages.

  • Percent ring

    Same native element, painted.

    45%

    Said with ariaNotify(): nothing yet.

    appearance: none · conic-gradient from --pct

    Visible number in an aria-hidden sibling.

  • Profile completion

    Task completion, so progress.

    60%
    Finish your profile

    value="60" max="100"

    Label says complete what.

Props

Props
PropDoesDefault
labelNames the bar. Always visible. Never “progress bar”: the role says itRequired
valueHow much is done, from 0 to max. Left out, the bar is indeterminate and that is all it means. Never "": that is a bar at 0%Absent
maxHow much work in total, in any unit. Left alone the browser makes it 1 and a value of 40 reads full100
valueTextThe value in words when the unit is not percent: “Step 3 of 8”. The same words are visible—
shownThe visible copy of the value. null shows nonevalueText, or the percent
lookbar, or ring: the same element, paintedbar
small · compactA thinner bar · the label and the bar on one row at any width, for a list of small barsfalse
stillThe still stripes of reduced motion on an indeterminate bar, whatever the setting of the personfalse
status · messageworking, done, failed or cancelled, and its words, beside the bar. A change of the words is said once, a tick never—
childrenThe controls: Cancel, Pause, Retry. Plain buttons—

Example

The words of message are what is said. Change them at the start, at a milestone and at the end, never on a tick.

import { Progress } from '@/components/Progress'

<Progress
  label="Uploading report.pdf"
  value={68}
  status="working"
  message="Uploading report.pdf"
>
  <button type="button" onClick={cancel}>Cancel</button>
</Progress>

Specs

Measurements

Measurements
PartPropertyValue
FieldGap8px · 12px (0.5rem · 0.75rem)
LabelText13px (0.8125rem) / 1.45, 700
BarHeight · border · corner12px (0.75rem) with its border · 2px · pill. Small: 8px (0.5rem)
FillHeight · cornerThe track less its border · pill
FillEasinginline-size 300ms ease-out, only where motion is allowed
Value textText12px (0.75rem) / 1.45, monospace, tabular numbers
StatusText · icon13px (0.8125rem) / 1.4, 600 · 16px icon, 6px gap
ControlSize · corner44 × 44px at least · 8px. WCAG 2.5.8 asks 24 × 24px
RingSize · edges64px (4rem) · 2px outside and inside
Name · value text · statusWidth from 48rem8rem · 3.5rem · 11rem

States

The element has no error, done or cancelled state, and the fill alone never says what happened: every state is said in words with an icon.

States
StateBarStatus line
IndeterminateNo value: a purple slice slides over the track“Uploading report.pdf”
Reduced motionStill stripes keep “unknown” without movement“Uploading report.pdf”
Started, 0%value="0": determinate and empty“Uploading report.pdf”
Determinate, 40%value="40": purple fill to 40%“Uploading report.pdf · 40%”, said at a milestone
DoneFullA check in purple: “Upload complete”
FailedThe fill stays where it stoppedA warning in red: “Upload failed. Retry?”
CancelledThe fill staysA crossed circle: “Upload cancelled”

Layout

The breakpoints are container queries in rem, so they follow the room the field has and the size of the text: a field in a narrow card stacks on a wide screen. One DOM, only the CSS changes. Each use case is a story in Storybook. Pick the width in the viewport menu of the toolbar, for any story: 640 CSS px · 200% zoom or 320 CSS px · 400% zoom.

Layout
BreakpointCSS widthReached byLayout
≥ 48rem≥ 768pxDesktop at 100–150% zoomOne row: name · bar · value · status · control
30–48rem480–767px1280px at 200% (640 CSS px) · tablets · text at 200%Name and value on one row, the bar full width, status and control below
< 30rem< 480px1280px at 400% (320 CSS px) · phonesStacked: name, bar, value, status, control. No sideways scroll

Attributes

No min: the scale always starts at 0. Indeterminate means one thing: value is absent. From script, progress.position is value ÷ max, or -1 when indeterminate; removeAttribute("value") makes it indeterminate again and progress.value = 0 writes value="0". In React pass value={undefined}, never value="".

Attributes
AttributeMeaningDefault
maxHow much work in total. Any unit: bytes, files, steps, 1001
valueHow much is done, from 0 to maxAbsent: indeterminate
aria-valuetextThe value in words when the unit is not percent: “Step 3 of 8”None: the number is read

Reading the markup

Reading the markup
Markup`position`Reads as
<progress>-1indeterminate
max="100", no value-1indeterminate
value="0"0determinate, empty
value="40" max="100"0.4determinate
value="40", no max1full bar: clamped to max 1
value="150" max="100"1clamped to max
value="-5" max="100"0clamped to 0
value="" or "abc"00%, not indeterminate
max="0", "-1" or "abc"by valuemax falls back to 1

Markup

<label for="upload">Uploading report.pdf</label>
<span aria-hidden="true">68%</span>
<progress id="upload" value="68" max="100">68%</progress>
<p><svg aria-hidden="true" focusable="false">…</svg> Uploading report.pdf</p>
<button type="button">Cancel</button>

Styling

One rule for each engine: a selector list of both is dropped whole by each.

progress {
  appearance: none;                  /* first, or the rules below are ignored */
  display: block;
  box-sizing: border-box;
  inline-size: 100%;
  block-size: 0.75rem;
  border: 2px solid #5e548e;
  border-radius: 999px;
  background-color: #ebebec;         /* the track, in Firefox */
  print-color-adjust: exact;         /* the fill is a background */
}
progress::-webkit-progress-bar   { background-color: #ebebec; border-radius: 999px; }
progress::-webkit-progress-value { background-color: #5e548e; border-radius: 999px; }
progress::-moz-progress-bar      { background-color: #5e548e; border-radius: 999px; }

/* No value: a slice slides over the element, and both fills are see-through */
progress:indeterminate {
  background-image: linear-gradient(90deg, transparent, #5e548e, transparent);
  background-size: 40% 100%;
  background-repeat: no-repeat;
  animation: slide 1.4s linear infinite;
}
progress:indeterminate::-webkit-progress-bar,
progress:indeterminate::-webkit-progress-value { background: transparent; }
progress:indeterminate::-moz-progress-bar { background: transparent; }
@keyframes slide { from { background-position: -100% 0; } to { background-position: 200% 0; } }

@media (prefers-reduced-motion: reduce) {
  progress:indeterminate {
    animation: none;
    background-image: repeating-linear-gradient(45deg, #5e548e 0 0.5rem, transparent 0.5rem 1rem);
    background-size: auto;
  }
}

Across engines

Across engines
ConcernChromium and SafariFirefox
Fill::-webkit-progress-value::-moz-progress-bar
Track::-webkit-progress-barThe element’s own background. No track pseudo-element
appearance: noneRequired: without it the native look winsRequired (MDN, untested)
BorderNone by defaultDraws its own: set one on purpose (untested)
Native height16px in Chromium, 21px in WebKitNot measured

Tokens

Component tokens

Component tokens
PartPropertyTokenValue
BarBorder widthbw2px
Bar, ringBorder colourlilac#5e548e
BarHeight · corner—12px (8px small) · 999px
TrackFill—#ebebec
FillFilllilac#5e548e
LabelText colourink#16161a
Value textText colourink-soft#4b4b55
Status, working and cancelledText colourink#16161a
Status, doneText and iconlilac#5e548e
Status, failedText and icondanger#82002a
Status icon, workingTrack · arc— · lilac#d7d7dc · #5e548e
ControlBorder · fill · cornerink · white · radius#16161a · #ffffff · 8px

Guidelines

Alternatives

The spec calls progress “the wrong element to use for something that is just a gauge”. If a bar also appears next to a stepper, the stepper is the source of truth.

Alternatives
ElementUse it forFinishesRole
progressWork under way, with or without a known totalYesprogressbar
meterA measurement in a known rangeNometer
ol + aria-current="step"One of several named stagesThe flow doeslist, listitem
Skeleton in aria-busyA layout waiting for its contentYesnone
NothingA wait of about a second or less——

Content

Content
PartFormatExample
LabelWhat is happening to what. Never “progress bar”“Uploading report.pdf” · “Importing contacts”
Value textThe bare percent, or words when the unit is not percent, the same as in aria-valuetext“68%” · “Step 3 of 8” · “28th · ~4 min”
StatusWhat the task is doing, in the words that are said“Uploading report.pdf” · “Upload complete” · “Upload failed. Retry?” · “Upload cancelled”
MilestoneThe status and the percent, every 25%“Uploading report.pdf · 50%”
ControlThe action“Cancel” · “Retry” · “Pause”

Behavior

Behavior
EventResult
A tick: the value changesThe bar moves. Nothing is said
The words of the status changeSaid once with ariaNotify() from the bar, after the DOM has changed
The total is unknownIndeterminate, and text that says what is happening. No invented percent, no bar stuck at 99%
The task takes over 10 secondsA determinate bar and a clearly signposted cancel
A control is pressedIt is not disabled: focus stays on it. aria-disabled="true" and the click ignored, or focus moved on purpose
Done“Complete” is said before the bar goes: it stays at 100% or the text replaces it
A region is loadingaria-busy="true" on it and aria-describedby at the bar. Removed when done
Not a running taskProfile completion: nothing is announced and there is no Cancel

Accessibility

Rules

Rules
RuleWhy
Name it with a labellabel for, or aria-label or aria-labelledby when there is no room. The text inside the element is a fallback, not a name. Not “progress bar” in the name
Native value, never ARIANo role="progressbar", no aria-valuenow, aria-valuemin or aria-valuemax: aria-valuenow overrides the bar
Get the two attributes rightOmit value for indeterminate, never value="". Set max for percentages: value="40" alone is a full bar
Not focusableNo tabindex. Tab goes to Cancel, Pause or Retry, and the control keeps focus after it is pressed
Ticks are silentThe start, the milestones, done and cancelled are said once. Failure with high priority. Say “complete” before the bar goes
Never colour aloneDone, failed and cancelled are words and an icon, with 3:1 of fill on track and a border. A red fill is not an error state

Keyboard

Keyboard
KeyActionNative
TabSkips the bar: it is not a tab stop. Lands on the controlYes
Enter, SpaceOn the control: Cancel, Pause or Retry. It keeps focusYes

Roles and properties

Roles and properties
PropertyExposedNote
RoleprogressbarImplicit. No other role is allowed
Name“Uploading report.pdf”From label for
Value68aria-valuetext when the unit is not percent
Range0 to 100From max
FocusNonetabIndex is -1: Tab goes to the control
LiveNoneThe bar never announces itself

Screen reader

Chromium’s accessibility tree for each markup, from the design. Listen in NVDA, JAWS and VoiceOver before trusting the milestone steps.

Screen reader
MarkupNameValueRangeValue text
label for, value="3" max="8" aria-valuetext="Step 3 of 8"The label30 to 8“Step 3 of 8”
No value, aria-labelThe aria-labelNone0 to 1—
value="40" max="100", fallback text “40%” onlyEmpty400 to 100—
value="40" max="100" aria-valuenow="90"—90 (!)0 to 100—

Announcements

A bar that moves is not a message. WCAG 4.1.3 covers the progress of a process, so the words are kept apart from the bar and say only what matters. They are the words of the status line, said once when they change; where ariaNotify is missing nothing is said and no live region replaces it. Every 25% is a starting point. Nothing here was heard in a screen reader yet.

Announcements
EventSaid byAnnouncedPriority
It startsariaNotify() from the bar“Uploading report.pdf”normal
Each tickNothing——
A milestone, every 25%ariaNotify() from the bar“Uploading report.pdf · 50%”normal
DoneariaNotify() from the bar“Upload complete”normal
It failsariaNotify() from the bar“Upload failed. Retry?”high: the task stopped and the next step is the person’s
It is cancelledariaNotify() from the bar“Upload cancelled”normal: a thing the person just did is not an emergency

Measured behavior

Chromium 148 and WebKit 26.4, from the design, 2026-10-16. The table of attributes was run again in Chromium here. Firefox rules are from MDN and untested.

Measured behavior
ObservationConsequence
aria-valuenow="90" on a bar at 40 shows 40 and announces 90Never set it
The text inside progress gives an empty namelabel for
Without appearance: none the native look wins and the pseudo-element rules are ignoredappearance: none first
A rule that lists ::-webkit-progress-value and ::-moz-progress-bar is dropped whole by each engine, inside :is() and :where() tooOne rule for each engine
With appearance: none an indeterminate bar draws nothingA gradient on the element, with both fills see-through
overflow: hidden for round ends leaves a seam in the end cap when border and fill are one colourA radius on each fill
The native fill grows from the right under dir="rtl"Nothing to do for a bar. A painted ring does not mirror by itself (untested)
The fill is a background: it is dropped in print unless background graphics are onprint-color-adjust: exact
Under forced colours, without a swap the fill and the track both become the page colourSwap to Highlight, Canvas and CanvasText
A bare progress under a reset that zeroes every border loses its native look and its animation (on this site)The component draws the bar itself

Support

Support
BrowserFill · trackChecked
Chromium::-webkit-progress-value · ::-webkit-progress-barYes
WebKit (Safari)::-webkit-progress-value · ::-webkit-progress-barYes
Firefox::-moz-progress-barYes

WCAG criteria

WCAG criteria
CriterionRequirement here
1.3.1 Info and Relationships (A)label for ties the label to the bar
1.4.1 Use of Color (A)Done, failed and cancelled are words and an icon, never colour alone
1.4.3 Contrast (Minimum) (AA)Label 18:1, value text 8.6:1, status words 6.7:1 (done) and 10.6:1 (failed)
1.4.4 Resize Text · 1.4.10 Reflow (AA)Rem sizes, inline-size: 100%, stacked under 30rem. No sideways scroll at 320 CSS px
1.4.11 Non-text Contrast (AA)Fill on track 5.62:1, border on page 6.70:1. The track on the page is 1.19:1, which is why the border is there
1.4.12 Text Spacing (AA)Names and status wrap, never clip
2.2.2 Pause, Stop, Hide (A)Beside other content and over 5 seconds: a cancel, and the still stripes under reduced motion
2.5.8 Target Size (Minimum) (AA)The controls are 44 × 44 CSS px at least. 24 × 24 is asked
4.1.2 Name, Role, Value (A)Name from the label, role from the element, value from value and max
4.1.3 Status Messages (AA)The start, the milestones, done, failed and cancelled reach assistive technology with ariaNotify(), without moving focus

Related

This section is still in progress.

References

This section is still in progress.