# Progress bars and meters that screen readers can't read

Source: https://easeweb.dev/learn/progress-bars
Topics: Live updates > Progress bars and meters say how far along they are (WCAG 4.1.2, 4.1.3)
The fix: Either use a progress element with a label, or give the drawn bar role="progressbar", a name and an aria-valuenow set with its fill, or hide the drawn bar and write its value as text beside it. Whichever you choose, announce a few milestones and the end in a role="status" region that is on the page from the start.
Test with: screen reader, keyboard
References: https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA25 https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/progress https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/progressbar_role https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/meter_role https://html.spec.whatwg.org/multipage/form-elements.html#the-meter-element https://htmhell.dev/adventcalendar/2022/5/

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

A support form lets you attach a file. You choose report.pdf and press Upload. Under the file name, a grey bar fills with blue over a few seconds, and when it is full, a tick and "Uploaded" appear. A sighted user watches it fill and waits. A screen reader user presses Upload and hears nothing. If they go looking, the bar is either not there at all, or it reads "progress bar, 0%" while it is drawn more than half full. They can't tell whether the upload started, how long it has left or whether it finished, so they press Upload again, or send the form before the file is attached.

## Why it fails

Two things are missing. The first is the bar itself. In one form of this failure it is two `div` elements, a track and a fill whose `width` the script changes, so it has no role, no name and no value: to assistive technology there is nothing there. In the other, someone added `role="progressbar"`, but with no name, and the script moves the fill without touching `aria-valuenow`, so a screen reader reads 0% to the end. Success Criterion 4.1.2 asks that components generated by script expose their name and role, and that changes to their values are available to assistive technology. Whether a bar that only shows a value counts as a "user interface component" is open to argument, and axe files an unnamed progress bar under 1.1.1 Non-text Content instead. Either way, the bar's meaning has to reach assistive technology, and the fixes are the same.

The second is the news. "Half done" and "Uploaded" are status messages: they report the progress and result of a process without taking focus, which is what 4.1.3 covers, and its Understanding page gives a progress bar with occasional announcements as an example. A progress bar is not a live region, so even a correct one is not announced when its value changes, unless the screen reader decides to follow it. NVDA, for example, is set by default to beep as a progress bar changes, higher as it fills, while other screen readers may stay silent until the person moves to the bar. A beep doesn't say that the upload finished, or that it failed.

## Who is affected

A bar is drawn to be watched, so anyone who isn't watching it misses how far it has got and the moment it ends. That includes sighted people who have moved on to the next field while the file uploads.

## What automation and AI miss

axe reports a `role="progressbar"` or `role="meter"` with no name, but a bar made of `div` elements has no role for it to check, and it doesn't check native `progress` and `meter` elements for a name. No scanner can tell that `aria-valuenow` stays at 0 while the fill grows, or that the end is never announced, because it sees the page at one moment. Asked to fix the bar, code assistants may add `role="progressbar"` and the three `aria-value*` attributes with fixed numbers, which turns silence into a wrong answer, or wrap the bar and its percentage in `aria-live`, so that every percent is read out. Only a screen reader, used during an upload, shows what is heard.

## Before: a bar drawn with divs

```html
<p>Uploading report.pdf</p>
<div class="bar"><div class="fill" style="width: 0%"></div></div>
<p class="done" hidden>Uploaded</p>
```

```js
await upload(file, (percent) => {
  fill.style.width = `${percent}%`;
});
done.hidden = false;
```

The bar has no role, name or value, so a screen reader finds nothing after the file name, and "Uploaded" appears without being announced. Here `upload()` stands for the site's own upload code, calling back with a percentage as the file goes up, as the `upload` progress events of `XMLHttpRequest` allow.

## Before: a progressbar role with no name and a value that never moves

```html
<p>Uploading report.pdf</p>
<div class="bar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
  <div class="fill" style="width: 0%"></div>
</div>
<p class="done" hidden>Uploaded</p>
```

The script is the same as for the drawn bar. A screen reader now finds a progress bar, but with no name it can't say what is uploading, and `aria-valuenow` stays at 0 while the fill grows, so the bar reads 0% from start to finish. The end is still silent.

## After: a native progress element

```html
<label for="upload-bar">Uploading report.pdf</label>
<progress id="upload-bar" max="100" value="0">0%</progress>
<p class="done" hidden>Uploaded</p>
<p id="upload-status" role="status" class="visually-hidden"></p>
```

```js
let said = 0;
try {
  await upload(file, (percent) => {
    bar.value = percent;
    bar.textContent = `${percent}%`;
    // Say it at each quarter, not on every update.
    const quarter = Math.floor(percent / 25) * 25;
    if (quarter > said && quarter < 100) {
      said = quarter;
      status.textContent = `${quarter}% uploaded`;
    }
  });
  done.hidden = false;
  status.textContent = 'report.pdf uploaded.';
} catch {
  status.textContent = 'report.pdf could not be uploaded. Try again.';
}
```

To the browser, `progress` is a progress bar: the label names it, and its `value` against `max` is passed to assistive technology, so a screen reader that moves to it reads its name, its role and its value. The text inside it is a fallback for browsers that can't draw it, and does not name it. The status region is on the page from the start and empty, so its messages are announced politely, a quarter at a time, then "report.pdf uploaded." The cost is the look: browsers draw the bar themselves, and changing its colours takes their own pseudo-elements, such as `::-webkit-progress-value` and `::-moz-progress-bar`.

## After: a custom progressbar role, when the bar must stay a drawing

```html
<p id="upload-label">Uploading report.pdf</p>
<div class="bar" role="progressbar" aria-labelledby="upload-label"
  aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
  <div class="fill" style="width: 0%"></div>
</div>
<p class="done" hidden>Uploaded</p>
<p id="upload-status" role="status" class="visually-hidden"></p>
```

```js
let said = 0;
try {
  await upload(file, (percent) => {
    // The drawing and the value change together.
    fill.style.width = `${percent}%`;
    bar.setAttribute('aria-valuenow', percent);
    // Say it at each quarter, not on every update.
    const quarter = Math.floor(percent / 25) * 25;
    if (quarter > said && quarter < 100) {
      said = quarter;
      status.textContent = `${quarter}% uploaded`;
    }
  });
  done.hidden = false;
  status.textContent = 'report.pdf uploaded.';
} catch {
  status.textContent = 'report.pdf could not be uploaded. Try again.';
}
```

The bar keeps its drawing, and the role makes it a progress bar. `aria-labelledby` names it with the line already on screen, and `aria-valuenow` is set in the same callback as the width, so the two can't drift apart. The same status region announces the milestones and the end. Everything the `progress` element would have done is now the script's job, so check the value against the drawing whenever the component changes.

## After: the value as text, the drawing hidden

```html
<p>Uploading report.pdf: <span id="upload-percent">0%</span></p>
<div class="bar" aria-hidden="true"><div class="fill" style="width: 0%"></div></div>
<p class="done" hidden>Uploaded</p>
<p id="upload-status" role="status" class="visually-hidden"></p>
```

```js
let said = 0;
try {
  await upload(file, (percent) => {
    fill.style.width = `${percent}%`;
    percentText.textContent = `${percent}%`;
    // Say it at each quarter, not on every update.
    const quarter = Math.floor(percent / 25) * 25;
    if (quarter > said && quarter < 100) {
      said = quarter;
      status.textContent = `${quarter}% uploaded`;
    }
  });
  done.hidden = false;
  status.textContent = 'report.pdf uploaded.';
} catch {
  status.textContent = 'report.pdf could not be uploaded. Try again.';
}
```

The bar stays a drawing, hidden from assistive technology, and its value is written as text in the line above it, where a screen reader reads it like any other line and sighted people see the number too. The empty `div` elements said nothing anyway; `aria-hidden` makes the choice plain to the next developer. It is the smallest change to an existing bar. What it gives up is the role: a screen reader doesn't know there is a progress bar here, so it can't follow it on its own: NVDA, for example, plays no progress beeps for it, as it does for the other two fixes. The status region carries all the news.

## Which option to use

All three give the bar's progress in words that stay current, and announce milestones and the end from the same status region. The native `progress` element takes the least code: the browser supplies the role, the name from the label and the value, and the script sets only `value`; choose it when the bar can be restyled through its pseudo-elements. The custom role keeps a bar drawn any way the design wants, at the cost of an attribute the script must keep in step with the fill; choose it when the bar comes from a component you can't swap, or the design needs more than a track and a fill. The value as text is the smallest change to an existing bar and puts the number on screen for everyone; choose it when you can change the text around the bar but not the bar itself, and accept that screen readers then meet no progress bar, and NVDA no longer beeps as it fills.

## Implementation decisions

**Announce milestones, not every percent.** An upload sends dozens of progress events, and a status message for each one becomes a queue of numbers the person has to sit through. Quarters suit a process that takes seconds. For a long one, such as a large export, say something every 10 seconds or so, or at each named stage ("Preparing", "Uploading", "Processing"). If it finishes within two or three seconds, announce only the end. Never put `aria-live` on the bar, or around a percentage that changes with it: that reads every change. A live text beside the bar, as technique ARIA25 shows, works when the script updates it only at milestones.

**Steps, not percentages.** A checkout or sign-up that shows "Step 2 of 4" as a bar has a value that isn't a percentage. Screen readers often read `aria-valuenow` as a percentage, so a value of 2 between 1 and 4 can come out as "2 percent". Set `aria-valuetext="Step 2 of 4: Delivery"` on the `role="progressbar"`, or show the steps as a list with `aria-current="step"` on the current one, which says more than a bar can.

**When there is no amount.** If you can't know how far along it is, leave the value out: a `progress` element with no `value` attribute, or a `role="progressbar"` with no `aria-valuenow`, is indeterminate, a sign that something is happening. That is a loading state, with its own pattern in [loading spinners that screen readers never announce](/learn/loading-states).

**Meters.** A meter shows a measurement within a known range, such as storage used or a password's strength, not the progress of a task, and HTML says not to use `meter` for a progress bar. Give it a label, as for `progress`: `<label for="storage">Storage used</label>` before `<meter id="storage" min="0" max="10" value="7.2">`. Screen readers report meters unevenly: in tests published in 2022, NVDA called a meter a progress bar, VoiceOver on macOS skipped one that had been styled, and none of them read its low or high marks. So write the value as text beside the meter as well ("7.2 GB of 10 GB"), and say in words what its colour means ("Strength: weak"). A custom meter takes `role="meter"`, a name, `aria-valuenow`, `aria-valuemin` and `aria-valuemax` (they default to 0 and 100), and an `aria-valuetext` with the units.

**Colours and forced colors.** Where the fill is the only place the value shows, it is a graphic that carries meaning, so it needs a contrast of 3:1 against the track; see [low-contrast form fields, icons and focus rings](/learn/non-text-contrast). A fill drawn with `background-color` disappears in a forced colors theme, where backgrounds are replaced; give it `background-color: CanvasText` inside `@media (forced-colors: active)`.

## Verify the fix

1. Inspect the DOM before an upload: the bar is a `progress` element with a label, or has `role="progressbar"` with a name, or is hidden with `aria-hidden="true"` with its value written as text beside it; and an empty `role="status"` region is already on the page.
2. With a screen reader on, start an upload from the keyboard: a message is announced at each milestone, not on every update, and focus stays on the button.
3. Midway, move to the bar with the screen reader: its name and a value that matches the drawing are read, or the text beside a hidden bar says the same as the drawing.
4. Let an upload finish, then make one fail (block the request in the browser's developer tools): the end and the failure are each announced in words.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the upload code or the progress component changes.

## Limits

Screen readers differ in how they report progress bars: some read the value as a percentage, some beep, and some speak only when the person moves to the bar, and their support for meters is weaker still. This guide covers one bar for one process on one page. A spinner or a wait with no amount is covered in [loading spinners that screen readers never announce](/learn/loading-states), and a message that isn't about progress, such as "Draft saved", in [status messages that are shown but never announced](/learn/status-messages). Test real uploads, slow and failing ones included, with the screen readers your users use.

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
