// 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.

aria-required intermediate wcag-2.2

// 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

HTML
<!-- 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>
CSS
/* 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;
  }
}
JavaScript
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));
  }
});
Why inject into a persistent live region? The toast container with 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)
Focus management Toasts should not steal focus from the user's current task. Instead, live regions announce the toast content passively. Users can choose to tab into the toast container if they want to interact with a dismiss button. This keeps the workflow uninterrupted — particularly important for form entry and other keyboard-intensive tasks.

// 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" or role="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-width and min-height properties.
  • 1.3.1 Info and Relationships Level A — Toast structure uses semantic roles to convey meaning. The role attribute communicates urgency level, and the aria-label on 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-label and 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

No live region on the toast container Without 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.
Auto-dismiss too fast A toast that disappears after 1-2 seconds does not give users enough time to read and comprehend the message. Users with cognitive disabilities, low vision, or those using screen magnifiers need more time. WCAG does not specify an exact minimum duration, but 5 seconds is a common baseline for non-critical messages, and the timer must be pausable.
No pause mechanism on auto-dismiss If the auto-dismiss timer runs regardless of user interaction, this violates SC 2.2.1 (Timing Adjustable). Users must be able to stop or extend the time limit. Pausing on mouseenter and focusin is the standard approach. Without this, users who are still reading the toast will lose it mid-sentence.
Using 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.
No dismiss button Without a visible, focusable dismiss button, keyboard users have no way to close a toast notification. This is especially problematic for persistent error toasts that never auto-dismiss. Even for auto-dismissing toasts, users should be able to close them early rather than waiting for the timer. An icon-only button with aria-label provides the necessary control.
Moving focus to the toast Programmatically moving focus to a toast notification disrupts the user's current workflow. If a user is filling out a form and submits it, stealing focus to a "Saved!" toast forces them to navigate back to where they were. Live regions solve this problem by announcing content without any focus change. Focus should only move to a toast if the user explicitly tabs into it.

// 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
The verdict Toast notifications are heavily dependent on ARIA. While <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.