// pattern
Accessible Toast / Notification Pattern
A toast notification system that uses ARIA live regions to announce messages without stealing focus. Success and info toasts auto-dismiss after 5 seconds with pause on hover and focus. Error toasts persist until the user explicitly dismisses them.
// 01 · live demo
Live Demo
Click a button to trigger a toast. Hover or focus a toast to pause auto-dismiss. Press Escape to dismiss all toasts.
Inaccessible version (no live region, no dismiss, too fast):
Bad toasts appear here — no live region, no dismiss button, disappears in 2 seconds.
// 02 · the code
The Code
<!-- Toast container: a persistent live region.
Content injected here is announced by screen readers. -->
<div class="toast-container"
id="toast-container"
aria-live="polite"
aria-relevant="additions">
<!-- Toasts are injected here dynamically -->
</div>
<!-- Individual toast structure (generated by JS) -->
<div class="toast toast--success" role="status">
<svg class="toast__icon" aria-hidden="true">...</svg>
<span class="toast__content">Settings saved successfully.</span>
<button type="button"
class="toast__dismiss"
aria-label="Dismiss notification">
<svg aria-hidden="true">...</svg>
</button>
</div>
<!-- Error toast: uses role="alert" for assertive announcement -->
<div class="toast toast--error" role="alert">
<svg class="toast__icon" aria-hidden="true">...</svg>
<span class="toast__content">Connection failed. Please try again.</span>
<button type="button"
class="toast__dismiss"
aria-label="Dismiss notification">
<svg aria-hidden="true">...</svg>
</button>
</div>
/* Fixed container in the bottom-right corner */
.toast-container {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
display: flex;
flex-direction: column-reverse;
gap: 0.5rem;
z-index: 1000;
max-width: 22rem;
width: 100%;
pointer-events: none;
}
/* Individual toast */
.toast {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 0.25rem;
background: #fff;
border: 1px solid #d1d5db;
border-left: 4px solid #d1d5db;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
font-size: 0.875rem;
pointer-events: auto;
animation: toast-slide-in 0.3s ease-out;
}
/* Toast type variants — left border color */
.toast--success { border-left-color: #16a34a; }
.toast--error { border-left-color: #dc2626; }
.toast--info { border-left-color: #5b2a86; }
/* Icon colors match the variant */
.toast--success .toast__icon { color: #16a34a; }
.toast--error .toast__icon { color: #dc2626; }
.toast--info .toast__icon { color: #5b2a86; }
.toast__icon {
flex-shrink: 0;
width: 1.25rem;
height: 1.25rem;
margin-top: 0.125rem;
}
.toast__content {
flex: 1;
line-height: 1.5;
}
/* Dismiss button — SC 2.5.8: minimum 24x24 target */
.toast__dismiss {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
min-width: 24px;
min-height: 24px;
padding: 0;
border: none;
background: none;
color: #6b7280;
cursor: pointer;
border-radius: 0.25rem;
}
.toast__dismiss:hover {
color: #1f2937;
background: #f3f4f6;
}
/* SC 2.4.13: focus indicator with 3:1 contrast */
.toast__dismiss:focus-visible {
outline: 2px solid #5b2a86;
outline-offset: 1px;
}
/* Slide-in animation */
@keyframes toast-slide-in {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* SC 2.3.3: respect reduced motion preference */
@media (prefers-reduced-motion: reduce) {
.toast {
animation: none;
}
}
function createToast(message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
const isError = type === 'error';
toast.className = `toast toast--${type}`;
// role="alert" for errors (assertive),
// role="status" for success/info (polite)
toast.setAttribute('role', isError ? 'alert' : 'status');
toast.innerHTML = `
<svg class="toast__icon" aria-hidden="true"
viewBox="0 0 20 20" fill="currentColor">
${getIconPath(type)}
</svg>
<span class="toast__content">${message}</span>
<button type="button" class="toast__dismiss"
aria-label="Dismiss notification">
<svg viewBox="0 0 20 20" fill="currentColor"
aria-hidden="true">
<path d="M6.28 5.22a.75.75 0 0 0-1.06
1.06L8.94 10l-3.72 3.72a.75.75 0
1 0 1.06 1.06L10 11.06l3.72
3.72a.75.75 0 1 0 1.06-1.06L11.06
10l3.72-3.72a.75.75 0 0
0-1.06-1.06L10 8.94 6.28 5.22Z"/>
</svg>
</button>
`;
// Dismiss button handler
const dismissBtn = toast.querySelector('.toast__dismiss');
dismissBtn.addEventListener('click', () => {
dismissToast(toast);
});
container.appendChild(toast);
// Auto-dismiss (not for errors)
if (!isError) {
let timeoutId;
let remaining = 5000;
let startTime;
function startTimer() {
startTime = Date.now();
timeoutId = setTimeout(
() => dismissToast(toast), remaining
);
}
function pauseTimer() {
clearTimeout(timeoutId);
remaining -= Date.now() - startTime;
}
// Pause on hover and focus
toast.addEventListener('mouseenter', pauseTimer);
toast.addEventListener('focusin', pauseTimer);
// Resume on leave
toast.addEventListener('mouseleave', startTimer);
toast.addEventListener('focusout', startTimer);
startTimer();
}
}
function dismissToast(toast) {
toast.remove();
}
function getIconPath(type) {
switch (type) {
case 'success':
return '<path fill-rule="evenodd" d="M10 18a8 8 0 '
+ '1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75 '
+ '.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a'
+ '.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 '
+ '0 1.137-.089l4-5.5Z" clip-rule="evenodd"/>';
case 'error':
return '<path fill-rule="evenodd" d="M10 18a8 8 0 '
+ '1 0 0-16 8 8 0 0 0 0 16ZM8.28 7.22a.75.75 '
+ '0 0 0-1.06 1.06L8.94 10l-1.72 1.72a.75.75 '
+ '0 1 0 1.06 1.06L10 11.06l1.72 1.72a.75.75 '
+ '0 1 0 1.06-1.06L11.06 10l1.72-1.72a.75.75 '
+ '0 0 0-1.06-1.06L10 8.94 8.28 7.22Z" '
+ 'clip-rule="evenodd"/>';
default: // info
return '<path fill-rule="evenodd" d="M18 10a8 8 0 '
+ '1 1-16 0 8 8 0 0 1 16 0Zm-7-4a1 1 0 1 1-2 '
+ '0 1 1 0 0 1 2 0ZM9 9a.75.75 0 0 0 0 1.5h'
+ '.253a.25.25 0 0 1 .244.304l-.459 2.066A1.75 '
+ '1.75 0 0 0 10.747 15H11a.75.75 0 0 0 '
+ '0-1.5h-.253a.25.25 0 0 1-.244-.304l.459-'
+ '2.066A1.75 1.75 0 0 0 9.253 9H9Z" '
+ 'clip-rule="evenodd"/>';
}
}
// Escape key dismisses all toasts
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const toasts = document.querySelectorAll('.toast');
toasts.forEach(toast => dismissToast(toast));
}
});
aria-live="polite" must exist in the DOM before content is added. When you inject a toast into this container, the screen reader detects the DOM change and announces the new content. If you create the live region at the same time as the content, many screen readers will miss the announcement entirely.
// 03 · why these decisions
Why These Decisions
Why role="status" for non-urgent vs role="alert" for urgent
role="status" creates a polite live region — the screen reader waits until the user is idle before announcing the message. This is appropriate for success confirmations and informational messages that do not require immediate action. role="alert" creates an assertive live region that interrupts whatever the screen reader is currently saying. Reserve this for errors and critical messages. Using role="alert" for everything would constantly interrupt users, creating a frustrating experience similar to a colleague who shouts every message regardless of importance.
Why a persistent container with aria-live
The toast container must already exist in the DOM with the aria-live attribute before any toasts are added. Screen readers monitor live regions for changes — they detect when new content is injected and announce it accordingly. If you dynamically create both the container and the toast at the same time, most screen readers will not detect the change because the live region was not being observed when the content appeared. The container sits empty in the DOM until a toast is needed.
Why auto-dismiss pauses on hover and focus
WCAG SC 2.2.1 (Timing Adjustable) requires that users can extend or stop time limits on content. A toast that disappears on a fixed timer regardless of user interaction fails this criterion. Pausing the countdown when a user hovers over or focuses within the toast gives them time to read the message and interact with the dismiss button. The timer resumes only when attention moves away from the toast.
Why error toasts persist until dismissed
Error messages require user acknowledgment. An error toast that auto-dismisses may vanish before the user reads it, leaving them confused about why an action failed. By requiring explicit dismissal, you ensure the user has seen the error and can take corrective action. This also prevents the frustrating pattern where a form submission fails, the error toast disappears, and the user has no idea what went wrong.
Why a visible dismiss button
Keyboard users need a focusable, activatable control to close toasts. An icon-only dismiss button with aria-label="Dismiss notification" provides this. Without a dismiss button, keyboard users have no way to close a persistent toast, and even auto-dismissing toasts cannot be manually closed before the timer expires. The dismiss button also benefits mouse users who want to clear a notification immediately.
Why toasts stack rather than replace
When multiple actions trigger toasts in quick succession, replacing the previous toast means users may miss important messages. Stacking toasts in a vertical list preserves all notifications until they are dismissed or expire. The column-reverse flex direction ensures the newest toast appears at the bottom, closest to where the user's attention already is, while older toasts remain visible above.
// 04 · keyboard interaction
Keyboard Interaction
| Key | Action |
|---|---|
| Tab | Moves focus to the dismiss button inside the next toast in DOM order |
| Shift + Tab | Moves focus to the dismiss button in the previous toast or back to the page content |
| Escape | Dismisses the currently focused toast, or all visible toasts if none is focused |
| Enter / Space | Activates the dismiss button (handled by native <button> — no extra JS needed) |
// 05 · wcag 2.2 success criteria
WCAG 2.2 Success Criteria
This pattern satisfies the following WCAG 2.2 success criteria:
-
4.1.3 Status Messages
Level AA
— Toast notifications use
role="status"orrole="alert"to announce content to screen readers without moving focus. This ensures status messages are programmatically determinable through role or properties. - 2.2.1 Timing Adjustable Level A — Auto-dismiss pauses when the user hovers over or focuses within the toast. The timer resumes only when attention moves away, giving users control over the timing.
-
2.1.1 Keyboard
Level A
— The dismiss button is a native
<button>element, fully operable via keyboard. Escape provides a shortcut to dismiss all toasts. - 1.4.1 Use of Color Level A — Toast types are distinguished by icons and text labels in addition to the colored left border. Color alone is never the only differentiator between success, error, and info states.
- 2.4.13 Focus Appearance Level AAA — The dismiss button has a visible 2px solid focus outline with 3:1 contrast ratio when focused via keyboard.
-
2.5.8 Target Size (Minimum)
Level AA
— The dismiss button meets the 24x24px minimum target size via
min-widthandmin-heightproperties. -
1.3.1 Info and Relationships
Level A
— Toast structure uses semantic roles to convey meaning. The
roleattribute communicates urgency level, and thearia-labelon the dismiss button conveys its purpose.
// 06 · screen reader behavior
Screen Reader Behavior
When a role="status" toast appears
- NVDA: Waits until the current speech finishes, then announces the toast text — "Settings saved successfully." No focus change occurs; the user stays in their current location.
- JAWS: Similar polite behavior. Announces the toast content after a brief pause in current speech output.
- VoiceOver: Announces the live region change with a subtle tone, then reads the toast content. Focus remains on the currently active element.
When a role="alert" toast appears
- NVDA: Immediately interrupts current speech and announces "Alert: Connection failed. Please try again." The assertive announcement takes priority over all other output.
- JAWS: Similar forceful interruption. May prefix the announcement with "Alert" to indicate the urgency. Focus does not move.
- VoiceOver: Triggers an immediate announcement, often with a distinct alert sound. Reads the full content right away regardless of other queued speech.
When tabbing to the dismiss button
- NVDA: "Dismiss notification, button" — announces the accessible name from
aria-labeland the button role - JAWS: "Dismiss notification, button" — same identification
- VoiceOver: "Dismiss notification, button" — reads the label and role
After dismissing, focus returns to the page content. If there are remaining toasts, the user can continue tabbing to their dismiss buttons.
Auto-dismiss behavior
When a toast auto-dismisses, screen readers do not make any announcement — the content is simply removed from the live region. This is intentional: announcing removal would create unnecessary noise. The user already heard the original message and does not need to be told it went away.
// 07 · common mistakes
Common Mistakes
aria-live or an equivalent role like role="status", screen readers have no way to detect that new content has been added to the page. The toast appears visually but is completely invisible to assistive technology users. They will never hear the notification, potentially missing critical error messages or success confirmations.
mouseenter and focusin is the standard approach. Without this, users who are still reading the toast will lose it mid-sentence.
role="alert" for everything
role="alert" triggers an assertive interruption — the screen reader stops whatever it is currently saying and announces the alert immediately. Using this for routine success messages ("Saved!") or informational notices creates an aggressive, disruptive experience. Reserve role="alert" for genuine errors and urgent notifications. Use role="status" for everything else.
aria-label provides the necessary control.
// 08 · native html vs. aria
Native HTML vs. ARIA
HTML has limited native support for live announcements. The <output> element has an implicit role="status", but toast notifications typically require more control over urgency levels and container behavior than native HTML provides.
| Feature | Native HTML | ARIA Requirement |
|---|---|---|
| Status announcement | <output> — implicit role="status" (polite) |
role="status" on a generic container |
| Urgent announcement | None | role="alert" — assertive live region |
| Urgency level control | None | aria-live="polite" vs aria-live="assertive" |
| Change detection scope | None | aria-relevant="additions" — only announce new content |
| Dismiss action | <button> — focusable, keyboard-activatable |
aria-label="Dismiss notification" for icon-only button |
| Icon semantics | None | aria-hidden="true" on decorative SVG icons |
| Container identity | <div> — structural container |
aria-live on the persistent container element |
| Toast type differentiation | None | Role choice (status vs alert) conveys urgency semantically |
<output> provides a native polite live region, it does not cover assertive announcements, and its styling limitations make it impractical for toast UI. The dismiss button benefits from a native <button>, but the announcement mechanism — the core accessibility feature of a toast — requires ARIA live regions.