Accessibility Developer Kit · Guide 18 / 20

Accessible Toast / Notification

Polite success + assertive error live regions, action toasts with pause-on-hover timing, and a single-region toast queue — Vanilla and React.

  • WCAG: 2.2.1 · 4.1.3 · 1.4.11
  • Level: Intermediate → Advanced
  • Icons: lucide-react (CheckCircle2, AlertTriangle, Undo2, X)
1

What we are building

Notifications that are seen and heard, with the right level of urgency and timing.

What we are building

A toast is a short, transient notification that appears without interrupting the user’s task. Three decisions matter: politeness (status vs alert), timing (auto-dismiss vs manual, pause on hover), and action (plain message vs undo button). We cover all three plus the single-region queue pattern.

2

The 4 real-world variants

Switch between the inaccessible version and the accessible fix.

The 4 real-world variants

Variant A

Polite success

Preferred
Live example

role="status"waits for a pause. SR announces: “Changes saved.” after current reading.

HTML · Accessible
<!-- role="status" = aria-live="polite" + aria-atomic="true" -->
<div role="status" class="toast toast--ok">
  <svg aria-hidden="true"><!-- check icon --></svg>
  <span>Changes saved.</span>
  <button type="button" aria-label="Dismiss notification">×</button>
</div>

<!-- Keep the region in the DOM BEFORE adding text. SRs sometimes
     miss the first update if the region is created on the fly. -->

role=status waits for a pause before SR announces. Perfect for 'Saved', 'Copied', 'Added to cart'.

Variant B

Assertive error

Urgent only
Live example

role="alert" interrupts SR immediately. Error toasts should NOT auto-dismiss.

HTML · Accessible
<!-- role="alert" = aria-live="assertive" + aria-atomic="true"
     Interrupts SR immediately. Do NOT auto-dismiss errors. -->
<div role="alert" class="toast toast--err">
  <svg aria-hidden="true"><!-- alert icon --></svg>
  <span>Payment failed. Your card was declined.</span>
  <button type="button" aria-label="Dismiss alert">×</button>
</div>

role=alert interrupts SR. Reserve for critical errors (payment, auth). Do NOT auto-dismiss.

Variant C

Action toast (Undo)

WCAG 2.2.1
Live example

Click Delete, then Tab → Undowithin 8 seconds. SR reads: “1 item moved to trash. Undo, button.”

HTML · Accessible
<!-- Action toast: longer timer + pause-on-hover + real button -->
<div role="status" class="toast">
  <span>1 item moved to trash.</span>
  <button type="button" class="toast-action">Undo</button>
  <button type="button" aria-label="Dismiss notification">×</button>
</div>

<script>
  let timer = setTimeout(() => dismiss(), 8000);   // &gt;= 8 seconds
  toast.addEventListener('mouseenter', () => clearTimeout(timer));
  toast.addEventListener('focusin',    () => clearTimeout(timer));
  toast.addEventListener('mouseleave', () => timer = setTimeout(dismiss, 8000));
  toast.addEventListener('focusout',   () => timer = setTimeout(dismiss, 8000));
</script>

<!-- WCAG 2.2.1 Timing Adjustable: allow pause/extend when user hovers
     or focuses, or give at least 20 seconds of warning if auto-dismissing. -->

Give at least 8 seconds AND pause the timer on hover / focus so users can read and reach the action.

Variant D

Toast queue

Preferred
Live example

Click several times quickly — each new toast appends into the same region.

HTML · Accessible
<!-- ONE persistent region, toasts append inside -->
<div id="toasts" aria-label="Notifications">
  <!-- toasts render here, each with role="status" -->
</div>

<script>
  function fire(text, { urgent = false } = {}) {
    const el = document.createElement('div');
    el.className = 'toast toast--ok';
    el.setAttribute('role', urgent ? 'alert' : 'status');
    el.textContent = text;
    document.getElementById('toasts').appendChild(el);
    if (!urgent) setTimeout(() => el.remove(), 5000);
  }
</script>

<!-- Keep the wrapper in the DOM from page load; toasts append inside.
     This avoids the "first toast is missed" problem. -->

ONE persistent region per politeness level, toasts append inside. Avoids SR missing the first toast.

3

Flagship implementation

A reusable ToastProvider + useToast() hook you can call from anywhere.

Flagship implementation

AccessibleToast.jsx
import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import "./AccessibleToast.css";

const ToastCtx = createContext({ fire: () => {} });
export const useToast = () => useContext(ToastCtx);

/**
 * Wrap your app with <ToastProvider />, then from anywhere:
 *   const { fire } = useToast();
 *   fire('Saved.');
 *   fire('Payment failed.', { urgent: true });
 *   fire('1 item deleted.', { action: { label: 'Undo', onClick: undo } });
 */
export function ToastProvider({ children }) {
  const [toasts, setToasts] = useState([]);

  const fire = useCallback((text, opts = {}) => {
    const id = Date.now() + Math.random();
    setToasts((t) => [...t, { id, text, ...opts }]);
  }, []);

  const remove = useCallback((id) => {
    setToasts((t) => t.filter((x) => x.id !== id));
  }, []);

  return (
    <ToastCtx.Provider value={{ fire }}>
      {children}
      <div className="toast-root" aria-label="Notifications">
        {toasts.map((t) => (
          <Toast key={t.id} toast={t} onDismiss={() => remove(t.id)} />
        ))}
      </div>
    </ToastCtx.Provider>
  );
}

function Toast({ toast, onDismiss }) {
  const { text, urgent = false, action } = toast;
  const timerRef = useRef(null);
  const paused = useRef(false);

  function schedule() {
    if (urgent) return;
    clearTimeout(timerRef.current);
    const ms = action ? 8000 : 5000;
    timerRef.current = setTimeout(onDismiss, ms);
  }

  useEffect(() => { schedule(); return () => clearTimeout(timerRef.current); }, []);

  return (
    <div
      role={urgent ? "alert" : "status"}
      className={`toast ${urgent ? "toast--err" : "toast--ok"}`}
      onMouseEnter={() => { paused.current = true; clearTimeout(timerRef.current); }}
      onMouseLeave={() => { paused.current = false; schedule(); }}
      onFocus={() => { paused.current = true; clearTimeout(timerRef.current); }}
      onBlur={() => { paused.current = false; schedule(); }}
    >
      <span>{text}</span>
      {action && (
        <button
          type="button"
          className="toast__action"
          onClick={() => { action.onClick?.(); onDismiss(); }}
        >
          {action.label}
        </button>
      )}
      <button
        type="button"
        className="toast__close"
        aria-label="Dismiss notification"
        onClick={onDismiss}
      >×</button>
    </div>
  );
}
Usage · App.jsx
import { ToastProvider, useToast } from "./AccessibleToast";

function Editor() {
  const { fire } = useToast();

  async function save() {
    try {
      await fetch('/api/save', { method: 'POST' });
      fire('Changes saved.');
    } catch {
      fire('Could not save. Try again.', { urgent: true });
    }
  }

  function del(id) {
    api.delete(id);
    fire('1 item moved to trash.', {
      action: { label: 'Undo', onClick: () => api.restore(id) },
    });
  }

  return (
    <>
      <button type="button" onClick={save}>Save</button>
      <button type="button" onClick={() => del(42)}>Delete</button>
    </>
  );
}

export default function App() {
  return (
    <ToastProvider>
      <Editor />
    </ToastProvider>
  );
}
4

Keyboard behavior

Toasts should not steal focus; actions must be keyboard-reachable.

Keyboard behavior

ActionExpected result
Toast appearsFocus is NOT stolen — user keeps working
TabEventually reaches the toast's action button + close button
Enter on actionFires action (e.g. Undo) then dismisses toast
Esc while focused insideOptional: dismiss the focused toast
Hover / focus-inPauses auto-dismiss (WCAG 2.2.1)
5

Screen reader expectations

Polite waits for a pause; assertive interrupts.

Screen reader expectations

  • Polite success: SR waits for a pause: “Changes saved.”
  • Assertive error: SR interrupts: “Alert, Payment failed. Your card was declined.”
  • Action toast: “1 item moved to trash. Undo, button.”
  • Dismiss button: “Dismiss notification, button”
6

WCAG mapping

Why each rule matters here.

WCAG mapping

Success CriterionWhy it matters
2.2.1 Timing Adjustable (A)Auto-dismiss must be at least 20s, OR pause/extend when the user hovers / focuses.
4.1.3 Status Messages (AA)Status must be programmatically determinable via role=status / role=alert.
1.4.11 Non-text Contrast (AA)Toast border, icon, and text must have 3:1 / 4.5:1 contrast.
2.1.1 Keyboard (A)Action buttons inside must be keyboard-reachable.
2.5.5 Target Size (AA, 2.2)Dismiss button at least 24×24 (prefer 44×44).
7

Testing approach

Framework-agnostic patterns. Pick the one that matches your stack.

Testing approach

Pattern: fire → assert role=status/alert and text → for action, click action and assert callback → for timing, wait > 5s with hover and assert still visible.

Jest + Testing Library + jest-axe

AccessibleToast.test.jsx
// Jest + @testing-library/react + jest-axe
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe, toHaveNoViolations } from "jest-axe";
import { ToastProvider, useToast } from "./AccessibleToast";

expect.extend(toHaveNoViolations);

function Trigger() {
  const { fire } = useToast();
  return (
    <>
      <button onClick={() => fire('Saved.')}>Save</button>
      <button onClick={() => fire('Error!', { urgent: true })}>Fail</button>
    </>
  );
}

test("polite toast uses role=status", async () => {
  const user = userEvent.setup();
  render(<ToastProvider><Trigger /></ToastProvider>);
  await user.click(screen.getByRole("button", { name: "Save" }));
  expect(await screen.findByRole("status")).toHaveTextContent(/saved/i);
});

test("urgent toast uses role=alert and does not auto-dismiss", async () => {
  const user = userEvent.setup();
  render(<ToastProvider><Trigger /></ToastProvider>);
  await user.click(screen.getByRole("button", { name: "Fail" }));
  const alert = await screen.findByRole("alert");
  expect(alert).toHaveTextContent(/error/i);
  // Wait longer than polite auto-dismiss window
  await new Promise(r => setTimeout(r, 200));
  expect(screen.getByRole("alert")).toBeInTheDocument();
});

test("has no axe violations", async () => {
  const { container } = render(<ToastProvider><Trigger /></ToastProvider>);
  expect(await axe(container)).toHaveNoViolations();
});

Vitest + Testing Library

AccessibleToast.test.jsx
// Vitest + @testing-library/react
import { expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ToastProvider, useToast } from "./AccessibleToast";

test("action button fires the callback", async () => {
  const user = userEvent.setup();
  const onUndo = vi.fn();
  function T() {
    const { fire } = useToast();
    return (
      <button onClick={() => fire('Deleted.', {
        action: { label: 'Undo', onClick: onUndo }
      })}>Delete</button>
    );
  }
  render(<ToastProvider><T /></ToastProvider>);
  await user.click(screen.getByRole("button", { name: "Delete" }));
  await user.click(await screen.findByRole("button", { name: "Undo" }));
  expect(onUndo).toHaveBeenCalled();
});

Playwright + @axe-core/playwright

toast.spec.js
// Playwright + @axe-core/playwright
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test("toast page has no serious a11y violations", async ({ page }) => {
  await page.goto("/en/accessible-toast");
  const results = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa", "wcag22aa"])
    .analyze();
  const serious = results.violations.filter(
    (v) => v.impact === "critical" || v.impact === "serious"
  );
  expect(serious).toEqual([]);
});

test("hover pauses auto-dismiss (WCAG 2.2.1)", async ({ page }) => {
  await page.goto("/en/accessible-toast");
  const btn = page.getByRole("button", { name: /save changes/i });
  await btn.click();
  const toast = page.getByRole("status").first();
  await expect(toast).toBeVisible();
  await toast.hover();
  // Wait past normal auto-dismiss window
  await page.waitForTimeout(6000);
  // Still visible because hover paused the timer
  await expect(toast).toBeVisible();
});
8

Manual QA checklist

Tick each item before shipping.

Manual QA checklist

  • Success / info uses role="status" (polite)
  • Errors use role="alert" (assertive)
  • One persistent toast region in the DOM — not created per toast
  • Toast does NOT steal focus when it appears
  • Action button is keyboard-reachable via Tab
  • Auto-dismiss is at least 5 seconds (8+ for action toasts)
  • Hover / focus-in pauses auto-dismiss (WCAG 2.2.1)
  • Error toasts do NOT auto-dismiss
  • Dismiss button has aria-label and ≥ 24×24 target
  • Zero critical / serious axe violations
9

Common mistakes

The toast bugs we see on nearly every audit.

Common mistakes

  • No role, just a styled div. SR says nothing. Add role=status / alert.
  • role=alert for everything. Interrupting the user for "Saved" is rude.
  • Auto-dismiss in 2 seconds. Fails 2.2.1. Minimum 5s, pause on hover.
  • Error toasts that auto-dismiss. User may miss it; keep critical errors manual.
  • Toast steals focus. Users get yanked out of their task.
  • New live region per toast. SR often misses the first update.
  • Low-contrast toast on dark pages. Light-on-light fails 1.4.11.
10

Download & next

Grab the code bundle, or jump to the next guide.

Download and next

© 2026 Aria Probe. All rights reserved
Powered by techtipsall Logo