Accessible Toast Notifications for Background Jobs
Permalink to "Accessible Toast Notifications for Background Jobs"Background jobs are everywhere in data products: an import finishes, an export is ready, a bulk update of 500 rows completes, a scheduled report fails. The usual messenger is a toast — a small panel that slides in at a corner and disappears after a few seconds. Toasts are among the most frequently inaccessible components in data UIs: not announced, gone before a screen reader user can reach them, containing an “Undo” or “Download” button that keyboard users cannot get to in time, or stealing focus from whatever the user was doing.
This guide belongs to notifications & toasts for data operations.
Spec reference
Permalink to "Spec reference"- SC 4.1.3 Status Messages (AA) — a job result shown without focus is a status message: it must be announced through
role="status"(oralertfor failures) without moving focus. - SC 2.2.1 Timing Adjustable (A) — a toast that disappears on a timer is a time limit on reading it. Users must be able to turn off, adjust or extend it, or the content must be available elsewhere. A notification history satisfies this.
- SC 2.1.1 Keyboard (A) — toast actions (Undo, Download, View) must be keyboard reachable.
- SC 2.4.3 Focus Order (A) and SC 3.2.1 On Focus (A) — focus must not jump to a toast unprompted.
- SC 1.4.13 Content on Hover or Focus (AA) — if hovering pauses the timer, focus must too.
When to use — and when not to
Permalink to "When to use — and when not to"Use toasts for results of work the user started but is no longer watching: “Import finished: 1,204 rows added, 3 rejected”, “Export ready”, “Bulk update complete”. They confirm, offer a next step, and get out of the way.
Do not use a toast as the only place a failure is reported, or the only place an important action lives. If an import rejected rows, the result belongs on the import page or in the history, with the toast as a pointer. Do not use toasts for validation errors in forms; those belong next to the fields.
The misapplication: a toast that appears for four seconds with an “Undo” button. Screen reader users hear the message (if they are lucky), and by the time they navigate to the corner, the toast — and Undo — is gone.
Annotated code example
Permalink to "Annotated code example"<!-- Rendered once, on page load (SC 4.1.3) -->
<section class="toasts" aria-label="Notifications">
<div id="toast-status" role="status" class="visually-hidden"></div>
<div id="toast-alert" role="alert" class="visually-hidden"></div>
<ol class="toast-stack"></ol> <!-- visible toasts -->
</section>
function notify({ text, kind = 'info', action }) {
history.push({ text, kind, action, at: new Date() }); // always recorded (SC 2.2.1)
// Announce via the persistent region, not via the new toast element
const region = kind === 'error' ? alertRegion : statusRegion;
region.textContent = '';
requestAnimationFrame(() => { region.textContent = text; });
const li = document.createElement('li');
li.className = `toast toast-${kind}`;
li.innerHTML = `<p>${escapeHtml(text)}</p>`;
if (action) {
const btn = Object.assign(document.createElement('button'), { type: 'button', textContent: action.label });
btn.addEventListener('click', action.run);
li.append(btn);
}
const close = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Dismiss' });
close.setAttribute('aria-label', `Dismiss: ${text}`);
close.addEventListener('click', () => li.remove());
li.append(close);
stack.append(li);
if (!action && kind !== 'error') autoHide(li, 8000); // only non-actionable toasts
}
function autoHide(el, ms) {
let t = setTimeout(() => el.remove(), ms);
const pause = () => clearTimeout(t);
const resume = () => { t = setTimeout(() => el.remove(), ms); };
el.addEventListener('mouseenter', pause); el.addEventListener('mouseleave', resume);
el.addEventListener('focusin', pause); el.addEventListener('focusout', resume); // SC 1.4.13
}
// Alt+T moves focus to the newest toast (documented shortcut)
document.addEventListener('keydown', (e) => {
if (e.altKey && e.key.toLowerCase() === 't') stack.lastElementChild?.querySelector('button')?.focus();
});
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Event | Expected announcement | AT deviations |
|---|---|---|
| Job completes | “Import finished: 1,204 rows added, 3 rejected.” | VoiceOver may wait for current speech |
| Job fails | “Scheduled report failed: data source unavailable.” (assertive) | — |
| Alt+T | Focus to newest toast action: “View rejected rows, button” | Some screen readers use Alt+T; make it configurable |
| Dismiss | Toast removed; focus returns to where it was before Alt+T | — |
| Toast auto-hides | Nothing | Message still in history |
Integration context
Permalink to "Integration context"Toasts are the transient face of a broader system; the persistent face is notification centres and unread counts. Timing rules in detail are in toast timing and WCAG 2.2.1. The choice between status and alert follows role status, alert and log compared.
Gotchas
Permalink to "Gotchas"Live region on the toast element. Putting role="status" on each new toast means the region is created at the same time as its content, which many readers ignore. Announce through a region that already exists.
Stacks of toasts. Ten bulk jobs finishing at once produce ten toasts and ten announcements. Collapse them: “10 jobs finished. 1 failed.” with a link to the history.
Shortcut collisions. Alt+T and similar shortcuts may collide with browser or screen reader commands in some languages. Make the shortcut configurable and list it in help.
Framework notes
Permalink to "Framework notes"Sonner and react-hot-toast (React) render toasts with role="status" on each item; add a persistent region or verify announcements across readers. Angular Material MatSnackBar uses the CDK LiveAnnouncer, which is a persistent region — good — but its default duration hides actions; set duration: 0 for actionable snack bars. Vue Toastification exposes timeout: false for persistence and pauseOnFocusLoss/pauseOnHover; add pause on focus inside the toast.
// Angular Material: actionable snack bar that persists until dismissed
this.snackBar.open('Import finished: 3 rows rejected.', 'View rejected rows', { duration: 0, politeness: 'polite' });
Worked example
Permalink to "Worked example"A user starts a bulk status change on 500 tickets and moves on to reading a ticket’s history. Thirty seconds later the job finishes with two failures. The toast appears bottom right — far outside her magnified viewport — and the persistent status region says “Bulk update finished: 498 tickets updated, 2 failed.” Because the toast has an action (“View failed tickets”), it stays. She finishes her paragraph, presses Alt+T, hears “View failed tickets, button”, and presses Enter. Had she missed the toast entirely, the bell’s name — “Notifications, 1 unread” — would have led her to the same place.
Testing checklist
Permalink to "Testing checklist"Design system notes
Permalink to "Design system notes"Expose one notify() API that decides politeness, timing and history from the message kind, and forbid direct use of the toast element. Centralising the decision is the only reliable way to stop teams from shipping four-second toasts with Undo buttons.
FAQ
Permalink to "FAQ"Should toasts have role="alert"?
Only for errors that need attention. Most job results are polite status messages; alerts for every completion interrupt users constantly.
How long should a toast stay visible?
Non-actionable confirmations: at least eight seconds, paused on hover and focus. Anything with an action or an error: until dismissed.
How do keyboard users reach a toast?
With a documented shortcut that moves focus to the newest toast, and through the notification centre. Never by having focus moved there automatically.
Where should toasts appear on screen?
Consistently in one place, and away from where magnifier users are likely to be working — but since magnifier users may still miss them, the history matters more than the position.
Related
Permalink to "Related"- Toast timing and WCAG 2.2.1 — the timing rules
- Notification centres — where messages persist
- Confirming and undoing destructive row actions — undo beyond the toast