Announcing Export Progress and Downloads
Permalink to "Announcing Export Progress and Downloads"A small export downloads instantly. A large one — 200,000 rows, a multi-sheet workbook, a PDF report — may take seconds or minutes while the server prepares it. Sighted users see a spinner or progress bar and wait. Screen reader users press the button and hear nothing, press it again, and start a second export. When the file finally arrives, the browser’s download notification may or may not be announced, and the user has no idea where the file went.
This guide belongs to data export & print.
Spec reference
Permalink to "Spec reference"- SC 4.1.3 Status Messages (AA) — “Preparing export”, progress milestones, “Export ready” and failures are status messages.
- SC 4.1.2 Name, Role, Value (A) — the native
progresselement exposes its value; a busy button exposesaria-disabledoraria-busy. - SC 2.2.1 Timing Adjustable (A) — a download link that expires must give enough time or a way to regenerate it.
- SC 3.2.5 Change on Request (AAA) — downloads should start only on user request; auto-downloading after a long wait can surprise users, so offering a link is the safer pattern at any level.
When to use — and when not to
Permalink to "When to use — and when not to"Show progress for any export likely to take more than about two seconds. Announce progress at coarse milestones (every 25 percent, or every 30 seconds) — enough to reassure, not enough to interrupt.
Do not announce every percentage change. A progress bar bound to a live region, or a progress element whose value changes are announced by the reader on every tick, produces a stream of numbers that drowns the user’s work. For very long jobs, do not keep the user waiting on the page at all: queue the export and notify them when it is ready.
The misapplication: an export button that becomes disabled and shows a spinner, then silently re-enables when the download starts. Focus is lost on disable, nothing is announced, and the download notification is browser-dependent.
Annotated code example
Permalink to "Annotated code example"<button type="button" id="export-btn" aria-describedby="export-progress-label">Export 182,000 invoices as Excel</button>
<div class="export-progress" hidden>
<label id="export-progress-label" for="export-progress">Preparing export</label>
<progress id="export-progress" max="100" value="0"></progress> <!-- native value (SC 4.1.2) -->
</div>
<p id="export-status" role="status" class="visually-hidden"></p>
<p id="export-ready" hidden></p>
let lastMilestone = 0;
exportBtn.addEventListener('click', async () => {
if (exportBtn.getAttribute('aria-disabled') === 'true') return;
exportBtn.setAttribute('aria-disabled', 'true'); // keep focus (SC 2.4.3)
progressBox.hidden = false;
status.textContent = 'Preparing Excel export of 182,000 rows. You can keep working.';
const job = await startExport();
job.onprogress = (pct) => {
progress.value = pct; // visual and on-demand value
const m = Math.floor(pct / 25) * 25;
if (m > lastMilestone && m < 100) { lastMilestone = m; status.textContent = `Export ${m} percent complete`; }
};
job.ondone = ({ url, name, size }) => {
progressBox.hidden = true;
exportBtn.removeAttribute('aria-disabled');
ready.hidden = false;
ready.innerHTML = `Export ready: <a href="${url}" download="${name}">Download ${name} (${size})</a>`;
status.textContent = `Export ready. Download ${name}, ${size}, is after the export button.`;
};
job.onerror = () => {
exportBtn.removeAttribute('aria-disabled');
alertEl.textContent = 'Export failed. Try again, or export fewer rows.'; // assertive
};
});
The download is offered as a link rather than triggered automatically. Users can activate it when ready, screen readers announce it as a link with the file name, and nothing downloads unexpectedly.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Event | Expected announcement | AT deviations |
|---|---|---|
| Activate export | “Preparing Excel export of 182,000 rows…” | — |
| Focus moves to the progress bar | “Preparing export, progress bar, 40 percent” | VoiceOver reads value on focus; NVDA may announce changes while focused |
| Milestones | “Export 50 percent complete” | — |
| Completion | “Export ready. Download invoices.xlsx, 14 MB…” | — |
| Activate download link | Browser download UI | Chrome announces “Download complete” in some configurations |
| Failure | “Export failed…” (assertive) | — |
Integration context
Permalink to "Integration context"Background completion messages belong in the notification system described in accessible toast notifications for background jobs, and long-running progress in general in progress indicators for long-running exports. The busy-button technique is the same as in retry controls for failed data loads.
Gotchas
Permalink to "Gotchas"Progress elements and verbose readers. NVDA announces progress value changes when the element has focus, and some configurations announce changes even without focus. Do not focus the progress bar automatically; let users check it on demand.
Navigation during export. If the user leaves the page, an in-page job may be lost. Either keep the job server-side and notify, or warn before navigation.
Expiring links. Signed download URLs often expire. Say how long the link is valid and offer “Regenerate” afterwards.
Framework notes
Permalink to "Framework notes"In React, keep export state in a store outside the grid component so navigating within the app does not cancel the job, and render the status region at the app root. In Angular, a service with a BehaviorSubject for progress and the CDK LiveAnnouncer for milestones works well. In Vue, a Pinia store holding job state lets a notification component anywhere in the app announce completion.
// Angular: milestone announcements from a progress stream
progress$.pipe(map(p => Math.floor(p / 25) * 25), distinctUntilChanged(), filter(m => m > 0 && m < 100))
.subscribe(m => this.announcer.announce(`Export ${m} percent complete`, 'polite'));
Testing checklist
Permalink to "Testing checklist"Design system notes
Permalink to "Design system notes"Provide an ExportJob component that owns the busy button, progress display, milestone announcements, ready link and failure state, and can hand off to the global notification centre when the user leaves. Milestone spacing should be a shared setting so every product announces at the same rhythm.
FAQ
Permalink to "FAQ"Should the download start automatically when ready?
Offering a link is more predictable. If you auto-download, announce it (“Downloading invoices.xlsx”) so users know to look in their downloads.
How often should progress be announced?
Every 25 percent, or every 30 seconds for very long jobs, is a good default. Let users check the exact value on demand through the progress element.
What if progress is unknown?
Use an indeterminate progress element (no value attribute) and announce elapsed time at intervals (“Still preparing, 1 minute so far”), or move to a background job.
Where should the completion message appear visually?
Next to the export button, where the user started, and in the notification centre if they have moved on.
Should users be able to cancel an export?
Yes, for anything that takes more than a few seconds. Turn the busy button into “Cancel export” (same element, new name), or add a Cancel button beside the progress bar, and announce “Export cancelled” when it stops.
Related
Permalink to "Related"- CSV and Excel exports — what is being exported
- Progress indicators for long-running exports — progress patterns in depth
- Toasts for background jobs — completion notifications