Announcing Upload Progress to Screen Readers
Render one empty role="status" live region with the page, and send it short messages only at meaningful moments: when uploading starts (“Uploading 12 photos”), at batch milestones (25, 50, 75 percent), when each file fails, and when the batch finishes (“12 photos uploaded” or “10 uploaded, 2 failed — see the list”). Throttle to at most one message every few seconds, merge messages that arrive in the same window, and use the assertive region only for errors that stop the whole batch. Keep numeric progress on each file’s native <progress> element, where users can read it on demand without it being spoken automatically.
A visual progress bar communicates continuously and silently: sighted users glance at it when they want to. The screen-reader equivalent is not to read every change aloud — at sixty updates a second that is either ignored by the screen reader or an unbearable stream of numbers — but to speak rarely, at moments that matter, and let users query details when they choose. WCAG 2.2 criterion 4.1.3 (Status Messages) requires that such status changes be programmatically announced without moving focus. This page belongs to accessible upload interfaces in frontend UX, chunking and progress tracking; the numbers it announces come from aggregating progress across multiple files.
When to use this approach
- Any interface that shows upload progress visually.
- Batches of several files, where per-file announcements would overwhelm.
- Long uploads, where silence for minutes leaves users unsure if anything is happening.
Prerequisites
- A live region in the initial HTML:
<div role="status" aria-live="polite" class="visually-hidden"></div>. - Aggregate progress for the batch (bytes sent and total) and per-file status events.
- NVDA, JAWS, VoiceOver or TalkBack for testing — they differ in how they queue and interrupt announcements.
What to announce, and when
Implementation
type Priority = "polite" | "assertive";
export class Announcer {
private queue: string[] = [];
private timer?: number;
private lastText = "";
constructor(private polite: HTMLElement, private assertive: HTMLElement, private minGapMs = 3000) {}
/** Queue a polite message; messages within the gap are merged into one. */
say(text: string) {
if (this.queue.at(-1) === text) return; // drop exact duplicates
this.queue.push(text);
this.timer ??= window.setTimeout(() => this.flush(), this.minGapMs / 3);
}
/** Interrupting message for errors that stop everything. Use sparingly. */
alert(text: string) { this.write(this.assertive, text); }
private flush() {
this.timer = undefined;
if (this.queue.length === 0) return;
const text = this.queue.splice(0).join(" ");
this.write(this.polite, text);
this.timer = window.setTimeout(() => { this.timer = undefined; if (this.queue.length) this.flush(); }, this.minGapMs);
}
private write(region: HTMLElement, text: string) {
// Clearing first makes screen readers announce text identical to the previous message.
region.textContent = "";
window.setTimeout(() => { region.textContent = text; this.lastText = text; }, 50);
}
}
// Wiring it to a batch
export function announceBatch(batch: EventTarget, a: Announcer, noun = { one: "photo", many: "photos" }) {
const n = (k: number) => `${k} ${k === 1 ? noun.one : noun.many}`;
let nextMilestone = 25;
batch.addEventListener("start", (e: any) => a.say(`Uploading ${n(e.detail.total)}.`));
batch.addEventListener("progress", (e: any) => {
const pct = Math.floor((e.detail.sentBytes / e.detail.totalBytes) * 100);
if (pct >= nextMilestone && nextMilestone < 100) {
a.say(nextMilestone === 50 ? "Half done." : `${nextMilestone} percent uploaded.`);
nextMilestone += 25;
}
});
batch.addEventListener("file-failed", (e: any) => a.say(`${e.detail.name} couldn't be uploaded: ${e.detail.reason}.`));
batch.addEventListener("done", (e: any) => {
const { ok, failed } = e.detail;
a.say(failed === 0 ? `${n(ok)} uploaded.` : `${ok} uploaded, ${failed} failed. The failed files are marked in the list.`);
});
batch.addEventListener("offline", () => a.alert("You're offline. Uploads are paused and will continue when you reconnect."));
}
Line-by-line on the decisions that matter
- Regions exist from page load. Screen readers watch live regions that are already in the accessibility tree. A region created at the same moment as its first message is frequently not announced.
- Polite by default.
role="status"impliesaria-live="polite": the screen reader waits until the user pauses before speaking, so announcements do not cut off what they are reading. Upload progress is never urgent enough to interrupt. - Assertive only for “everything stopped”. Going offline or losing authorisation changes what the user should do right now. That justifies interrupting; a single file failing does not.
- Merging within a window. Several files finishing within a second produce one combined message rather than five that queue up and drag on. The gap (three seconds here) sets the maximum announcement rate.
- Clear, then set. Screen readers announce changes; writing the same text twice is not a change. Clearing and setting after a short delay makes repeated messages (“1 file added.”) audible each time.
- Milestones at quarters. Four progress messages for a batch of any size keep users informed without noise. For very long uploads (tens of minutes), add a time-based “still uploading, 40 percent” every few minutes as reassurance.
Per-file progress on demand
Each file row carries its own detail: name, size, a native <progress> labelled by the file name, and a status string. None of these are live regions. When a user moves to a row, the screen reader reads “report.pdf, 2.4 megabytes, progress bar 45 percent, Uploading” — exactly the information they asked for. Update the progress element’s value and the status text as often as you like; because they are not live, they generate no speech.
Do not put aria-live on the list itself. Every row change would be announced, reproducing the flood the announcer exists to prevent. Similarly, avoid aria-busy toggling on the whole list during uploads; some screen readers then refuse to read the list until it clears.
Wording that works when heard
Messages are heard once, often while the user is doing something else, so they must stand on their own. Lead with the outcome (“12 photos uploaded”), use the same nouns as the visible interface (“photos”, not “items” or “assets”), and avoid symbols that read badly — “45 percent” rather than “45%”, which some voices read as “45 per cent sign”. Keep each under about fifteen words. For failures, include the file name and the reason, and say where to act: “The failed files are marked in the list” tells the user where to go next without moving their focus.
Localise announcements with the rest of the interface, including plural rules. Intl.PluralRules handles languages with more than two plural forms, and a screen reader speaking a message in the wrong language is as confusing as untranslated visible text.
Differences between screen readers
Screen readers do not treat live regions identically, so test the same flow in more than one. NVDA queues polite messages and reads them in order, which makes unthrottled regions fall far behind the real state. JAWS tends to read the latest message and may drop older queued ones. VoiceOver on macOS sometimes ignores updates made within a few milliseconds of each other, which is why the announcer clears the region and sets the text after a short delay. TalkBack reads polite messages after the current utterance but can be interrupted by focus changes the user makes.
The practical rule that survives all of them is the one this page follows: few messages, each complete on its own, spaced a few seconds apart, with details left in the page for users to read when they want. If a message only makes sense when heard immediately after the previous one, some users will hear it out of order or not at all.
Configuration gotchas
Nothing is announced in Safari with VoiceOver. The region is inside an element with display: none or visibility: hidden, or was added with its text. Use a clip-based visually-hidden class, not display: none, and render the region empty on load.
Announcements are cut off or skipped. Updating the region faster than the screen reader speaks makes it drop earlier messages. Throttle, and merge instead of replacing.
Every percent is read aloud. aria-live was put on the progress bar or the list. Remove it; only the announcer region is live.
JAWS reads messages twice. Both role="status" and an explicit aria-live="polite" on nested elements. Use one live region, not nested ones.
Verification
- With NVDA and Firefox, upload twelve files: count the announcements — around six, none overlapping.
- With VoiceOver on iOS, start an upload and swipe to a file row: its progress is read on demand.
- Toggle offline in DevTools during an upload: the assertive message interrupts once.
- Add the same single file twice in a row: “1 file added.” is announced both times.
Frequently Asked Questions
Should I announce each file as it completes?
For a handful of files, yes. For larger batches, merged or milestone messages are kinder; the list shows each file’s state for anyone who wants it.
Is aria-busy useful during uploads?
Rarely. It tells assistive technology to wait until content settles, which can hide the list for the whole upload. Leave it off unless a region is being replaced wholesale.
Do I need different wording for screen readers and visible text?
Usually the same words work for both, which keeps the experience consistent. The visible version can be terser because it sits next to context the listener cannot see.