Progress
Indicators
Open in Storybook: Progress (opens in a new tab)
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
- Label
label forNames the bar. Always visible. “Progress bar” is not part of the name.
- Value text
span aria-hidden="true"The visible copy of the value. Hidden because the bar already exposes it.
- Track
progress,::-webkit-progress-barThe whole scale, 0 to
max. The element holds the role, the name, the value and the range. - Fill
::-webkit-progress-value,::-moz-progress-barThe part done. With no
valueit is absent and a slice slides over the track. - Status
pwith ansvgWhat the task is doing, in words and a shape. A change of its words is said once.
- 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.
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
progressper file, the label is the file nameAn overall bar needs its own label.
Importing contacts
Unit is not percent.
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.
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-busyon the regionRemove
aria-busywhen done.Sync of unknown length
Indeterminate + text.
Said with ariaNotify(): nothing yet.
No
value· text says what is happeningNo invented percentages.
Percent ring
Same native element, painted.
Said with ariaNotify(): nothing yet.
appearance: none·conic-gradientfrom--pctVisible number in an
aria-hiddensibling.Profile completion
Task completion, so progress.
value="60" max="100"Label says complete what.
Props
| Prop | Does | Default |
|---|---|---|
label | Names the bar. Always visible. Never “progress bar”: the role says it | Required |
value | How 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 |
max | How much work in total, in any unit. Left alone the browser makes it 1 and a value of 40 reads full | 100 |
valueText | The value in words when the unit is not percent: “Step 3 of 8”. The same words are visible | — |
shown | The visible copy of the value. null shows none | valueText, or the percent |
look | bar, or ring: the same element, painted | bar |
small · compact | A thinner bar · the label and the bar on one row at any width, for a list of small bars | false |
still | The still stripes of reduced motion on an indeterminate bar, whatever the setting of the person | false |
status · message | working, done, failed or cancelled, and its words, beside the bar. A change of the words is said once, a tick never | — |
children | The 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
| Part | Property | Value |
|---|---|---|
| Field | Gap | 8px · 12px (0.5rem · 0.75rem) |
| Label | Text | 13px (0.8125rem) / 1.45, 700 |
| Bar | Height · border · corner | 12px (0.75rem) with its border · 2px · pill. Small: 8px (0.5rem) |
| Fill | Height · corner | The track less its border · pill |
| Fill | Easing | inline-size 300ms ease-out, only where motion is allowed |
| Value text | Text | 12px (0.75rem) / 1.45, monospace, tabular numbers |
| Status | Text · icon | 13px (0.8125rem) / 1.4, 600 · 16px icon, 6px gap |
| Control | Size · corner | 44 × 44px at least · 8px. WCAG 2.5.8 asks 24 × 24px |
| Ring | Size · edges | 64px (4rem) · 2px outside and inside |
| Name · value text · status | Width from 48rem | 8rem · 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.
| State | Bar | Status line |
|---|---|---|
| Indeterminate | No value: a purple slice slides over the track | “Uploading report.pdf” |
| Reduced motion | Still 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 |
| Done | Full | A check in purple: “Upload complete” |
| Failed | The fill stays where it stopped | A warning in red: “Upload failed. Retry?” |
| Cancelled | The fill stays | A 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.
| Breakpoint | CSS width | Reached by | Layout |
|---|---|---|---|
| ≥ 48rem | ≥ 768px | Desktop at 100–150% zoom | One row: name · bar · value · status · control |
| 30–48rem | 480–767px | 1280px at 200% (640 CSS px) · tablets · text at 200% | Name and value on one row, the bar full width, status and control below |
| < 30rem | < 480px | 1280px at 400% (320 CSS px) · phones | Stacked: 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="".
| Attribute | Meaning | Default |
|---|---|---|
max | How much work in total. Any unit: bytes, files, steps, 100 | 1 |
value | How much is done, from 0 to max | Absent: indeterminate |
aria-valuetext | The value in words when the unit is not percent: “Step 3 of 8” | None: the number is read |
Reading the markup
| Markup | `position` | Reads as |
|---|---|---|
<progress> | -1 | indeterminate |
max="100", no value | -1 | indeterminate |
value="0" | 0 | determinate, empty |
value="40" max="100" | 0.4 | determinate |
value="40", no max | 1 | full bar: clamped to max 1 |
value="150" max="100" | 1 | clamped to max |
value="-5" max="100" | 0 | clamped to 0 |
value="" or "abc" | 0 | 0%, not indeterminate |
max="0", "-1" or "abc" | by value | max 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
| Concern | Chromium and Safari | Firefox |
|---|---|---|
| Fill | ::-webkit-progress-value | ::-moz-progress-bar |
| Track | ::-webkit-progress-bar | The element’s own background. No track pseudo-element |
appearance: none | Required: without it the native look wins | Required (MDN, untested) |
| Border | None by default | Draws its own: set one on purpose (untested) |
| Native height | 16px in Chromium, 21px in WebKit | Not measured |
Tokens
Component tokens
| Part | Property | Token | Value |
|---|---|---|---|
| Bar | Border width | bw | 2px |
| Bar, ring | Border colour | lilac | #5e548e |
| Bar | Height · corner | — | 12px (8px small) · 999px |
| Track | Fill | — | #ebebec |
| Fill | Fill | lilac | #5e548e |
| Label | Text colour | ink | #16161a |
| Value text | Text colour | ink-soft | #4b4b55 |
| Status, working and cancelled | Text colour | ink | #16161a |
| Status, done | Text and icon | lilac | #5e548e |
| Status, failed | Text and icon | danger | #82002a |
| Status icon, working | Track · arc | — · lilac | #d7d7dc · #5e548e |
| Control | Border · fill · corner | ink · 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.
| Element | Use it for | Finishes | Role |
|---|---|---|---|
progress | Work under way, with or without a known total | Yes | progressbar |
meter | A measurement in a known range | No | meter |
ol + aria-current="step" | One of several named stages | The flow does | list, listitem |
Skeleton in aria-busy | A layout waiting for its content | Yes | none |
| Nothing | A wait of about a second or less | — | — |
Content
| Part | Format | Example |
|---|---|---|
| Label | What is happening to what. Never “progress bar” | “Uploading report.pdf” · “Importing contacts” |
| Value text | The bare percent, or words when the unit is not percent, the same as in aria-valuetext | “68%” · “Step 3 of 8” · “28th · ~4 min” |
| Status | What the task is doing, in the words that are said | “Uploading report.pdf” · “Upload complete” · “Upload failed. Retry?” · “Upload cancelled” |
| Milestone | The status and the percent, every 25% | “Uploading report.pdf · 50%” |
| Control | The action | “Cancel” · “Retry” · “Pause” |
Behavior
| Event | Result |
|---|---|
| A tick: the value changes | The bar moves. Nothing is said |
| The words of the status change | Said once with ariaNotify() from the bar, after the DOM has changed |
| The total is unknown | Indeterminate, and text that says what is happening. No invented percent, no bar stuck at 99% |
| The task takes over 10 seconds | A determinate bar and a clearly signposted cancel |
| A control is pressed | It 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 loading | aria-busy="true" on it and aria-describedby at the bar. Removed when done |
| Not a running task | Profile completion: nothing is announced and there is no Cancel |
Accessibility
Rules
| Rule | Why |
|---|---|
| Name it with a label | label 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 ARIA | No role="progressbar", no aria-valuenow, aria-valuemin or aria-valuemax: aria-valuenow overrides the bar |
| Get the two attributes right | Omit value for indeterminate, never value="". Set max for percentages: value="40" alone is a full bar |
| Not focusable | No tabindex. Tab goes to Cancel, Pause or Retry, and the control keeps focus after it is pressed |
| Ticks are silent | The start, the milestones, done and cancelled are said once. Failure with high priority. Say “complete” before the bar goes |
| Never colour alone | Done, 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
| Key | Action | Native |
|---|---|---|
| Tab | Skips the bar: it is not a tab stop. Lands on the control | Yes |
| Enter, Space | On the control: Cancel, Pause or Retry. It keeps focus | Yes |
Roles and properties
| Property | Exposed | Note |
|---|---|---|
| Role | progressbar | Implicit. No other role is allowed |
| Name | “Uploading report.pdf” | From label for |
| Value | 68 | aria-valuetext when the unit is not percent |
| Range | 0 to 100 | From max |
| Focus | None | tabIndex is -1: Tab goes to the control |
| Live | None | The 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.
| Markup | Name | Value | Range | Value text |
|---|---|---|---|---|
label for, value="3" max="8" aria-valuetext="Step 3 of 8" | The label | 3 | 0 to 8 | “Step 3 of 8” |
No value, aria-label | The aria-label | None | 0 to 1 | — |
value="40" max="100", fallback text “40%” only | Empty | 40 | 0 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.
| Event | Said by | Announced | Priority |
|---|---|---|---|
| It starts | ariaNotify() from the bar | “Uploading report.pdf” | normal |
| Each tick | Nothing | — | — |
| A milestone, every 25% | ariaNotify() from the bar | “Uploading report.pdf · 50%” | normal |
| Done | ariaNotify() from the bar | “Upload complete” | normal |
| It fails | ariaNotify() from the bar | “Upload failed. Retry?” | high: the task stopped and the next step is the person’s |
| It is cancelled | ariaNotify() 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.
| Observation | Consequence |
|---|---|
aria-valuenow="90" on a bar at 40 shows 40 and announces 90 | Never set it |
The text inside progress gives an empty name | label for |
Without appearance: none the native look wins and the pseudo-element rules are ignored | appearance: none first |
A rule that lists ::-webkit-progress-value and ::-moz-progress-bar is dropped whole by each engine, inside :is() and :where() too | One rule for each engine |
With appearance: none an indeterminate bar draws nothing | A 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 colour | A 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 on | print-color-adjust: exact |
| Under forced colours, without a swap the fill and the track both become the page colour | Swap 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
| Browser | Fill · track | Checked |
|---|---|---|
| Chromium | ::-webkit-progress-value · ::-webkit-progress-bar | Yes |
| WebKit (Safari) | ::-webkit-progress-value · ::-webkit-progress-bar | Yes |
| Firefox | ::-moz-progress-bar | Yes |
WCAG criteria
| Criterion | Requirement 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.