Accessibility pattern · Feedback and status

Progress bar

A native progress element, labelled and described, gives the role and the value for free; leave out the value and it becomes indeterminate. A screen reader hears the progress at 25, 50, 75 and 100 percent from a polite status, not at every tick, and Pause and Cancel keep focus where it is.

WCAG criteria
5
Keyboard rules
4
Checked with
axe, keyboard and the inspector

Live demo

Try it

Use it with a mouse, a keyboard or a screen reader. The inspector beside it shows what the browser tells assistive technology as you go: focus, state changes and announcements.

Diwali 2026

Shared album · 7 photos, 24 MB

0 uploaded, 7 left

Checkout

Step 2 of 4: Delivery

The demo works without JavaScript only as far as its HTML does; the inspector needs JavaScript.

Keyboard

Keys it answers to

Every action works without a pointer.

KeyWhat it does
Enter or SpaceOn Start upload, starts it; the same button then pauses and resumes it, and focus stays on it.
Enter or SpaceOn Cancel, stops the upload. Cancel stays focusable but unavailable when there is nothing to cancel, so focus is never lost.
Enter or SpaceOn Back or Next, moves the checkout bar one step; the step is announced.
TabMoves between the buttons; the progress bars are read in place and take no tab stop.

Screen readers

What it announces

Written from the roles, names and states in the markup.

WhenExpected announcement
Reading reaches the upload barUploading 7 photos, progress bar, 45%. 3 uploaded, 4 left
The upload passes 25%25% uploaded
The progress moves between milestonesNothing: the bar changes quietly
The upload finishes100% uploaded. Making thumbnails…, then Upload complete. 7 photos added to Diwali 2026.
Pause is pressedUpload paused
Next is pressed in the checkoutStep 3 of 4: Payment

These are expected announcements, not recordings. Wording and order differ between screen readers and browsers.

Code

Copy the code

The exact files this demo runs on. The styles are served with a prefix that keeps this site's own styles out of the demo; what you copy is the original.

The markup

<div class="ap-progress-bar" data-ap-progress-bar data-state="idle">
  <section class="ap-progress-bar__card" aria-labelledby="progress-album-h">
    <div class="ap-progress-bar__top">
      <span class="ap-progress-bar__art" aria-hidden="true">
        <svg viewBox="0 0 24 24" focusable="false"><rect x="3" y="6" width="15" height="13" rx="2"/><path d="M7 3h12a2 2 0 0 1 2 2v11"/><circle cx="8" cy="11" r="1.5"/><path d="m18 16-4-4-7 7"/></svg>
      </span>
      <div>
        <h3 class="ap-progress-bar__h" id="progress-album-h">Diwali 2026</h3>
        <p class="ap-progress-bar__sub">Shared album · 7 photos, 24 MB</p>
      </div>
    </div>

    <div class="ap-progress-bar__row">
      <label class="ap-progress-bar__label" for="progress-upload" data-ap-label>Ready to upload 7 photos</label>
      <span class="ap-progress-bar__pct" aria-hidden="true" data-ap-pct>0%</span>
    </div>
    <progress class="ap-progress-bar__bar" id="progress-upload" max="100" value="0" aria-describedby="progress-files" data-ap-bar></progress>
    <p class="ap-progress-bar__files" id="progress-files"><span data-ap-done>0</span> <span>uploaded,</span> <span data-ap-left>7</span> <span>left</span></p>
    <ol class="ap-progress-bar__tiles" aria-hidden="true" data-ap-tiles><li><svg viewBox="0 0 24 24" focusable="false"><rect x="4" y="5" width="16" height="14" rx="2"/><circle cx="9" cy="10" r="1.6"/><path d="m20 16-4.5-4.5L7 19"/></svg></li><li><svg viewBox="0 0 24 24" focusable="false"><rect x="4" y="5" width="16" height="14" rx="2"/><circle cx="9" cy="10" r="1.6"/><path d="m20 16-4.5-4.5L7 19"/></svg></li><li><svg viewBox="0 0 24 24" focusable="false"><rect x="4" y="5" width="16" height="14" rx="2"/><circle cx="9" cy="10" r="1.6"/><path d="m20 16-4.5-4.5L7 19"/></svg></li><li><svg viewBox="0 0 24 24" focusable="false"><rect x="4" y="5" width="16" height="14" rx="2"/><circle cx="9" cy="10" r="1.6"/><path d="m20 16-4.5-4.5L7 19"/></svg></li><li><svg viewBox="0 0 24 24" focusable="false"><rect x="4" y="5" width="16" height="14" rx="2"/><circle cx="9" cy="10" r="1.6"/><path d="m20 16-4.5-4.5L7 19"/></svg></li><li><svg viewBox="0 0 24 24" focusable="false"><rect x="4" y="5" width="16" height="14" rx="2"/><circle cx="9" cy="10" r="1.6"/><path d="m20 16-4.5-4.5L7 19"/></svg></li><li><svg viewBox="0 0 24 24" focusable="false"><rect x="4" y="5" width="16" height="14" rx="2"/><circle cx="9" cy="10" r="1.6"/><path d="m20 16-4.5-4.5L7 19"/></svg></li></ol>

    <div class="ap-progress-bar__acts">
      <button type="button" class="ap-btn ap-btn--primary ap-progress-bar__go" data-ap-go>
        <svg class="ap-btn__icon ap-progress-bar__i-start" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 16V4"/><path d="m7 9 5-5 5 5"/><path d="M4 16v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/></svg>
        <svg class="ap-btn__icon ap-progress-bar__i-pause" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M9 5v14M15 5v14"/></svg>
        <svg class="ap-btn__icon ap-progress-bar__i-resume" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M8 5v14l11-7Z"/></svg>
        <span data-ap-go-label>Start upload</span>
      </button>
      <button type="button" class="ap-btn" aria-disabled="true" data-ap-cancel>Cancel</button>
    </div>
    <p class="ap-progress-bar__sr" role="status" data-ap-say></p>
  </section>

  <section class="ap-progress-bar__card" aria-labelledby="progress-steps-h">
    <div class="ap-progress-bar__row">
      <h3 class="ap-progress-bar__h" id="progress-steps-h">Checkout</h3>
      <p class="ap-progress-bar__step" role="status" data-ap-step-text>Step 2 of 4: Delivery</p>
    </div>
    <div class="ap-progress-bar__steps">
      <progress class="ap-progress-bar__bar" max="4" value="2" aria-labelledby="progress-steps-h" aria-valuetext="Step 2 of 4: Delivery" data-ap-steps></progress>
      <span class="ap-progress-bar__gaps" aria-hidden="true"></span>
    </div>
    <div class="ap-progress-bar__acts">
      <button type="button" class="ap-btn" data-ap-back>Back</button>
      <button type="button" class="ap-btn ap-btn--primary" data-ap-next>Next</button>
    </div>
  </section>
</div>

WCAG 2.2

What it meets

The success criteria this pattern takes care of, and how.

  • 1.3.1 Info and Relationships Level A

    The label element is tied to the upload bar, and the file count is its description, so both are read with it. The photo tiles only repeat the count and are hidden.

  • 1.4.11 Non-text Contrast Level AA

    The filled part of each bar clears 3:1 against its track in both themes, and the percentage is also written out.

  • 2.3.3 Animation from Interactions Level AAA

    The sweeping stripe of the indeterminate bar and the filling animation stop when the system asks for reduced motion; the words still say what is happening.

  • 4.1.2 Name, Role, Value Level A

    The native progress element exposes the progressbar role and its value; the checkout bar's aria-valuetext says Step 3 of 4 instead of a bare percentage.

  • 4.1.3 Status Messages Level AA

    Milestones, the pause and the result are announced from a polite status, without moving focus.

Usage

When to use it

Use it

  • A task that takes more than a couple of seconds and whose size is known, like an upload, an export or an import.
  • A known number of steps, shown as how far through them someone is.

Use something else

  • Waits under a second or so: a progress bar that flashes past is noise.
  • A value within a known range that is not progress, like disk use or a password's strength: use a meter.
  • Steps people can go back to by name: show them as progress steps with links.

Common failures

How it usually goes wrong

  • Announcing every tick

    A live region updated on every percent reads numbers non-stop. The bar changes quietly; only the quarters and the result are announced.

  • A bar with no name

    A progress bar that says only 45% leaves people asking what is at 45%. The label element names it Uploading 7 photos.

  • A div that only looks like a bar

    A styled div has no role and no value, so nothing is exposed. The native progress element brings both, and its look is styled.

  • Disabled buttons that drop focus

    Disabling Cancel at the end of an upload throws focus to the top of the page. aria-disabled keeps it focusable and says it is unavailable.

  • No way to stop it

    An upload that cannot be paused or cancelled traps people into waiting. Both buttons work from the keyboard at any point.

  • A percentage for steps

    Step 2 of 4 read out as 50% hides what people need to know. aria-valuetext gives the step and its name instead.

Notes

Building it

  • Leaving out the value attribute, or removing it, makes a progress element indeterminate: the same element covers preparing, uploading and processing.
  • The milestone messages go into a role=status element that is in the HTML from the start. A live region has to exist before its text changes, so screen readers know to watch it; one created with its first message is often silent.
  • aria-valuetext replaces the number a screen reader would say. Use it when a percentage would mislead, as for steps, and leave it off when the percentage is the point.
  • The visible percentage is aria-hidden because the progress element already exposes it; without that, reading the card would say 45% twice.
  • The upload is simulated; the Slow connection option makes it slower so the pause and cancel can be tried at leisure.

Sources: HTML: the progress element · WAI-ARIA 1.2: the progressbar role · Understanding SC 4.1.3: Status Messages

Checked with axe in light and dark themes, at desktop and phone widths, and by keyboard. Not yet tested with every screen reader and browser pair. Report a correction