Progress Indicators for Long-Running Data Operations
Permalink to "Progress Indicators for Long-Running Data Operations"Long-running data operations — importing a CSV, exporting a large dataset, applying a bulk edit to 10,000 rows, rebuilding an index — need progress feedback. A spinner tells sighted users “something is happening”; a bar tells them how much is left. Screen reader users need the same two facts, delivered without a running commentary of percentages, and keyboard users need a way to cancel. This guide covers the progress indicator itself; it is used by export, import and bulk-operation flows across the site.
It belongs to notifications & toasts for data operations.
Spec reference
Permalink to "Spec reference"- HTML
progresselement — maps to the ARIAprogressbarrole;valueandmaxgive determinate progress; omittingvaluemakes it indeterminate. Needs a label (label for,aria-labelledby). - SC 4.1.2 Name, Role, Value (A) — name and current value must be exposed; native
progressdoes both. - SC 4.1.3 Status Messages (AA) — start, milestones, completion and failure are status messages.
- SC 2.2.2 Pause, Stop, Hide (A) — animated spinners that run more than five seconds alongside other content need a way to stop them, or should stop animating; respecting
prefers-reduced-motionhelps. - SC 2.3.3 Animation from Interactions (AAA) — reduced motion preferences.
When to use — and when not to
Permalink to "When to use — and when not to"Use determinate progress whenever you can count work — rows processed, bytes uploaded. Use indeterminate progress with an explanatory label when you cannot (“Waiting for the server to prepare your export”). Pair either with milestone announcements and a Cancel control when cancellation is possible.
Do not use a progress bar for operations under about a second; the flash is distracting. Do not wire progress value changes to a live region. The misapplication: aria-live="polite" on a container that holds the percentage text, producing “1 percent, 2 percent, 3 percent…” in the speech queue.
Annotated code example
Permalink to "Annotated code example"<div class="op-progress">
<label for="imp" id="imp-label">Importing invoices.csv</label>
<!-- Native progressbar: name + value exposed (SC 4.1.2) -->
<progress id="imp" max="100" value="0" aria-describedby="imp-detail"></progress>
<span id="imp-detail">0 of 12,400 rows</span>
<button type="button" id="imp-cancel">Cancel import</button>
</div>
<p id="op-status" role="status" class="visually-hidden"></p>
let milestone = 0;
function onProgress(done, total) {
const pct = Math.round(done / total * 100);
bar.value = pct;
detail.textContent = `${done.toLocaleString()} of ${total.toLocaleString()} rows`; // on-demand detail
const m = Math.floor(pct / 25) * 25;
if (m > milestone && m < 100) { milestone = m; status.textContent = `Import ${m} percent complete`; }
}
function onIndeterminate(reason) {
bar.removeAttribute('value'); // indeterminate state
label.textContent = reason; // "Waiting for the server to validate the file"
}
cancelBtn.addEventListener('click', async () => {
await job.cancel();
status.textContent = 'Import cancelled. No rows were added.'; // say what the cancel means
});
/* Respect reduced motion for indeterminate animation (SC 2.2.2, 2.3.3) */
@media (prefers-reduced-motion: reduce) {
progress:indeterminate::-webkit-progress-bar,
.spinner { animation: none; }
}
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Event | Expected announcement | AT deviations |
|---|---|---|
| Tab to the progress bar | “Importing invoices.csv, progress bar, 40 percent, 4,960 of 12,400 rows” | progress elements are not focusable by default; reached in browse mode or via the description |
| Milestone | “Import 50 percent complete” | — |
| Indeterminate | “Waiting for the server…, progress bar, busy” | VoiceOver says “indeterminate” |
| Cancel | “Import cancelled. No rows were added.” | — |
| Completion | “Import finished: 12,397 rows added, 3 rejected.” | — |
Integration context
Permalink to "Integration context"Exports use this indicator in announcing export progress and downloads; bulk operations in debouncing status messages for bulk operations. When a job finishes in the background, the result moves to accessible toast notifications for background jobs. Partial failures are summarised as in error summaries for batch operation failures.
Gotchas
Permalink to "Gotchas"NVDA progress beeps. NVDA can beep for progress bar updates depending on its settings, even without focus. Users who find it noisy can change it, but avoid updating the value more often than needed — once per percent is plenty.
Custom divs. A div styled as a bar needs role="progressbar", aria-valuenow, aria-valuemin, aria-valuemax and a name. The native element is simpler and less error-prone.
Cancel semantics. “Cancel” must say what happens to work already done — rolled back, or kept. Put it in the confirmation message.
Framework notes
Permalink to "Framework notes"MUI LinearProgress renders role="progressbar" with aria-valuenow when variant="determinate"; pass aria-label or aria-labelledby. Angular Material mat-progress-bar likewise; set mode="indeterminate" when unknown. Vuetify v-progress-linear exposes aria-valuenow and accepts aria-label. In every case, add milestone announcements yourself — none of them do it.
<LinearProgress variant="determinate" value={pct} aria-labelledby="imp-label" aria-describedby="imp-detail" />
Worked example
Permalink to "Worked example"A data team runs nightly reindexing that users can also trigger manually; it takes two to six minutes. Originally the page showed an animated spinner and the words “Working…”. The redesign shows “Rebuilding search index” as the label of a determinate progress element (documents processed out of total), a detail line with counts and an estimated time remaining, and a “Cancel rebuild” button whose confirmation explains that the old index stays in use. Screen reader users hear the start, three milestones and the result; NVDA users who want more can read the bar’s value at any time. With reduced motion enabled, the bar fills without its shimmer animation. Support tickets about “stuck” rebuilds dropped because everyone could now tell a slow job from a frozen one.
Testing checklist
Permalink to "Testing checklist"Design system notes
Permalink to "Design system notes"Provide an OperationProgress component that combines the labelled progress element, detail text, Cancel and milestone announcements, with milestone spacing as a shared setting. Product teams pass counts and a label; the component handles the rest.
FAQ
Permalink to "FAQ"Should progress bars be focusable?
Not usually. Screen reader users can read them in browse mode, and their description gives detail. Making them focusable adds tab stops with nothing to operate.
How often should the value update?
Once per percent at most. More frequent updates waste work and can trigger reader beeps.
What should indeterminate progress say?
What is happening and, if possible, why it cannot be measured: “Waiting for the server to prepare the export”. Add elapsed time after long waits.
Is a spinner ever enough?
For waits under a few seconds, a spinner with a text label (“Loading invoices”) and aria-busy on the region is fine. Beyond that, show progress or explain the wait.
Should estimated time remaining be shown?
If you can estimate it honestly, yes — in the detail text, updated occasionally. Avoid announcing it; estimates jump around, and hearing “2 minutes… 5 minutes… 1 minute” is worse than silence.
Related
Permalink to "Related"- Export progress and downloads — progress in the export flow
- Debouncing status messages for bulk operations — timing for bulk work
- Accessible skeleton screens versus spinners — loading indicators for content