Toast Notification Motion and Announcements
Part of Focus & State Motion Accessibility in Accessible Motion Architecture.
The problem
A toast slides up from the bottom corner to confirm that a document was saved, then slides away after three seconds. Screen reader users hear nothing, because the toast container is created at the same moment the message appears. Keyboard users cannot dismiss it. A user reading slowly misses it entirely. And when three actions happen quickly, the toasts overlap because each one animates independently from the same position.
Toasts are the clearest case where motion, timing and announcements have to be designed together.
Root cause analysis: live regions announce changes to regions that already exist
A live region must be present before the change. Assistive technology observes a live region and announces changes to it. If the element carrying aria-live is inserted at the same time as its content, there is nothing to observe until after the change, and many screen readers announce nothing. The container must be in the DOM from page load, empty.
Role choice determines interruption. role="status" — implicitly aria-live="polite" — queues the message until the user is idle. role="alert" — implicitly assertive — interrupts immediately, which is correct for errors that block progress and hostile for “Saved”.
Auto-dismiss is a timing problem. WCAG’s timing guidance expects users to be able to extend or disable time limits on content they need to read. A fixed three seconds is too short for longer messages and for anyone reading with magnification. Base the duration on message length, set a generous minimum, and pause while the user hovers or focuses the toast.
Animation must not delay the announcement. The message text should be in the live region as soon as the toast is created; the entrance animation runs at the same time. Inserting the element first and setting its text after the animation delays the announcement by the animation’s duration.
Stacking needs layout, not luck. Several toasts in a column need each to know where the others are — a flex column in the live region handles that, and new toasts push existing ones with a transition rather than overlapping them.
Step-by-step resolution
Production code pattern
<!-- Present from page load, empty. -->
<div class="toasts" role="status" aria-live="polite" aria-relevant="additions"></div>
<div class="toasts toasts--urgent" role="alert" aria-live="assertive"></div>
.toasts {
position: fixed;
inset-block-end: 1rem;
inset-inline-end: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
pointer-events: none; /* the region itself is not interactive */
}
.toast {
pointer-events: auto;
translate: 0 12px;
opacity: 0;
transition:
translate var(--motion-enter-duration) var(--motion-enter-easing),
opacity var(--motion-enter-duration) linear;
}
.toast[data-state="shown"] { translate: 0 0; opacity: 1; }
.toast[data-state="leaving"] {
translate: 0 8px;
opacity: 0;
transition-duration: var(--motion-exit-duration);
}
@media (prefers-reduced-motion: reduce) {
.toast, .toast[data-state="leaving"] { translate: none; transition: opacity 120ms linear; }
}
const region = document.querySelector('.toasts');
const urgent = document.querySelector('.toasts--urgent');
export function toast(message, { urgent: isUrgent = false, action } = {}) {
const el = document.createElement('div');
el.className = 'toast';
el.textContent = message; // text set BEFORE insertion: announced at once
const close = document.createElement('button');
close.type = 'button';
close.textContent = 'Dismiss';
close.addEventListener('click', () => dismiss(el));
el.append(close);
if (action) el.append(action);
(isUrgent ? urgent : region).append(el);
requestAnimationFrame(() => { el.dataset.state = 'shown'; });
// Reading time: about 50 ms per character, minimum 5 s, maximum 20 s.
let remaining = Math.min(20000, Math.max(5000, message.length * 50));
let start = performance.now();
let timer = setTimeout(() => dismiss(el), remaining);
const pause = () => { clearTimeout(timer); remaining -= performance.now() - start; };
const resume = () => { start = performance.now(); timer = setTimeout(() => dismiss(el), remaining); };
el.addEventListener('pointerenter', pause);
el.addEventListener('focusin', pause);
el.addEventListener('pointerleave', resume);
el.addEventListener('focusout', resume);
el.addEventListener('keydown', (e) => { if (e.key === 'Escape') dismiss(el); });
return el;
}
async function dismiss(el) {
el.dataset.state = 'leaving';
await Promise.allSettled(el.getAnimations().map((a) => a.finished));
el.remove();
}
Rendering Impact: composite for entrance and exit; the flex column repositions remaining toasts when one is removed, which is a small layout for the region only.
Putting the close button inside the toast makes the toast focusable content inside a live region. That is acceptable for role="status", but it means the toast must not disappear while focus is inside it — which the pause-on-focusin handler ensures.
When a toast is the wrong pattern
A toast is a transient, non-modal message that the user does not have to act on. If the message requires action — confirming a destructive operation, re-entering credentials — it belongs in a dialog that takes focus, not in a toast that disappears. If it reports the result of a form submission, an inline message near the form is easier to find again, as in animating form validation feedback accessibly.
Toasts also make poor error surfaces when several errors can occur at once: assertive announcements interrupt each other, and stacked error toasts are hard to read. Aggregate them into one message or one persistent region instead.
Verification checklist
Constraints and trade-offs
- Focusable content inside a live region can be announced twice by some screen readers.
- Length-based timing is a heuristic; offer a setting to disable auto-dismiss where messages matter.
- Assertive announcements interrupt whatever the user is doing; use sparingly.
- Stacked toasts can cover content in the corner; limit the number shown at once.
- A toast that is never noticed is a design problem no announcement fixes.
Frequently asked questions
Why is my toast not announced by screen readers?
The live region was probably created at the same time as the message. Render the region empty on page load and insert messages into it.
Should toasts use role=status or role=alert?
status for ordinary confirmations, alert only for errors that interrupt the user’s task. Assertive announcements cut across whatever is being read.
How long should a toast stay visible?
Long enough to read: base it on message length with a minimum of several seconds, and pause the timer while the user hovers or focuses it.
Should focus move to a toast?
No. Toasts are non-modal. Keep any actions inside them reachable by Tab, and use a dialog when the user must respond.
Related
- Focus & State Motion Accessibility — the parent topic
- Animating ARIA Live Region Updates — announcement mechanics in depth
- Providing Pause Controls for Autoplay Motion — time limits and user control