react nextjs accessibility patterns
Accessible Toast Notifications in React
Toast notifications are deceptively hard to get right. The visual layer is trivial—a positioned div, a slide-in transition, an auto-dismiss timer—but the accessibility layer is where most implementations silently fail. A toast that screen reader users never hear is not a notification; it is decoration. This guide shows how to build React toasts that announce reliably through assistive technology, distinguish routine confirmations from urgent errors, never steal keyboard focus, and respect users who read slowly. The patterns here build on Dynamic Content & State Announcements and the wider set of React & Next.js Accessibility Patterns, and they map directly to WCAG 4.1.3 Status Messages and 2.2.1 Timing Adjustable.
The central insight is that toasts are status messages: content that conveys a change in application state without moving focus. WCAG 4.1.3 Status Messages (Level AA) requires that such messages be programmatically determinable through role or properties so assistive technology can announce them without a focus change. Get the live region semantics right and the rest is incremental.
Prerequisites
Before implementing, ensure your environment and mental model are in place:
- React 18+ with a client component boundary (
'use client'in Next.js App Router). - A portal target at the document root so toasts render above the rest of the tree without inheriting
overflow: hiddenor stacking-context traps. - Familiarity with the two ARIA roles that matter here:
role="status"(implicitaria-live="polite") androle="alert"(implicitaria-live="assertive"). - A
.sr-onlyutility class is not required for visible toasts, but you will need correct contrast and focus handling for the dismiss control.
A critical rule before any code: the live region container must already exist in the DOM before you write text into it. Screen readers register live regions when they are inserted into the accessibility tree. If you mount the container and its message in the same render, many AT/browser combinations miss the announcement entirely. Render the empty region container at app startup; mutate its children later.
The Live Region Container
Mount a persistent region pair once, near the root. Routine toasts feed the polite region; errors feed the assertive region. Keeping both permanently in the tree is what makes announcements dependable.
'use client';
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react';
type ToastTone = 'info' | 'success' | 'error';
interface Toast {
id: number;
message: string;
tone: ToastTone;
}
interface ToastApi {
notify: (message: string, tone?: ToastTone) => void;
}
const ToastContext = createContext<ToastApi | null>(null);
let nextId = 0;
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const timers = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
const dismiss = useCallback((id: number) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
const timer = timers.current.get(id);
if (timer) {
clearTimeout(timer);
timers.current.delete(id);
}
}, []);
const notify = useCallback(
(message: string, tone: ToastTone = 'info') => {
const id = nextId++;
setToasts((prev) => [...prev, { id, message, tone }]);
// Errors stay until dismissed; routine toasts auto-expire (see 2.2.1).
if (tone !== 'error') {
const timer = setTimeout(() => dismiss(id), 6000);
timers.current.set(id, timer);
}
},
[dismiss],
);
return (
<ToastContext.Provider value={{ notify }}>
{children}
<ToastViewport toasts={toasts} onDismiss={dismiss} />
</ToastContext.Provider>
);
}
export function useToast(): ToastApi {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used within a ToastProvider');
return ctx;
}
The provider owns timers in a ref so re-renders never drop or duplicate them, and it separates error tone (persistent) from routine tones (auto-dismissing). That separation is the seed of 2.2.1 Timing Adjustable compliance, which we complete below.
Rendering the Viewport: status vs. alert
The viewport renders two distinct live regions. Polite toasts wait their turn so they never interrupt the user mid-sentence; assertive toasts cut in because the user must know now.
function ToastViewport({
toasts,
onDismiss,
}: {
toasts: Toast[];
onDismiss: (id: number) => void;
}) {
const polite = toasts.filter((t) => t.tone !== 'error');
const assertive = toasts.filter((t) => t.tone === 'error');
return (
<>
{/* Routine confirmations: announced when the SR is idle (4.1.3). */}
<div role="status" aria-live="polite" aria-atomic="false" className="toast-region">
{polite.map((t) => (
<ToastItem key={t.id} toast={t} onDismiss={onDismiss} />
))}
</div>
{/* Errors interrupt immediately. role="alert" implies assertive. */}
<div role="alert" aria-live="assertive" aria-atomic="false" className="toast-region">
{assertive.map((t) => (
<ToastItem key={t.id} toast={t} onDismiss={onDismiss} />
))}
</div>
</>
);
}
Use aria-atomic="false" so the screen reader announces only the newly inserted toast, not the entire region every time a toast is added or removed. The choice of role is not cosmetic: reserve role="alert" for genuine errors and time-sensitive warnings. Overusing assertive announcements is itself an accessibility defect because it trains users to ignore interruptions.
The diagram below shows how the same user action routes to different politeness levels.
Focus-Safe Dismissal and Timing
A toast must never steal focus. Moving focus to a toast on appearance violates user expectations and can yank a keyboard or screen reader user out of the form they are completing. Instead, the toast item is a non-focusable container with a single focusable dismiss button that carries an accessible name.
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: (id: number) => void }) {
return (
<div className={`toast toast--${toast.tone}`}>
<span className="toast__message">{toast.message}</span>
<button
type="button"
className="toast__dismiss"
onClick={() => onDismiss(toast.id)}
// Accessible name independent of the icon glyph.
aria-label={`Dismiss notification: ${toast.message}`}
>
<span aria-hidden="true">×</span>
</button>
</div>
);
}
The aria-label gives the button a meaningful name even though it renders a bare glyph; the glyph itself is hidden from AT with aria-hidden="true" so it is not double-announced.
For 2.2.1 Timing Adjustable, three properties matter. First, errors never auto-dismiss—the provider above leaves them in the DOM until the user dismisses them. Second, routine toasts use a generous default (six seconds is a reasonable floor; the WCAG technique baseline is twenty seconds for content the user must read, so calibrate to message length). Third, pause on hover and focus so a user who is reading is never cut off. Pausing also satisfies users with cognitive or reading disabilities who need extra time.
// Extend ToastProvider with hover/focus pause for the routine region.
function ToastViewportTimed({ toasts, onDismiss, onPauseAll, onResumeAll }: {
toasts: Toast[];
onDismiss: (id: number) => void;
onPauseAll: () => void;
onResumeAll: () => void;
}) {
return (
<div
role="status"
aria-live="polite"
aria-atomic="false"
className="toast-region"
// Reading the toast pauses every active dismissal timer (2.2.1).
onMouseEnter={onPauseAll}
onMouseLeave={onResumeAll}
onFocusCapture={onPauseAll}
onBlurCapture={onResumeAll}
>
{toasts.map((t) => (
<ToastItem key={t.id} toast={t} onDismiss={onDismiss} />
))}
</div>
);
}
To implement pause cleanly, store each timer's remaining duration rather than a fixed handle: on pause, clearTimeout and record remaining = expiresAt - Date.now(); on resume, schedule a fresh timeout for remaining. This keeps the timing logic correct across repeated hover cycles.
How to Verify
Automated checks catch structural regressions; manual checks confirm the announcement actually fires.
axe / jest-axe. Render the provider with a toast present and assert no violations, then assert the roles exist:
import { render, screen, act } from '@testing-library/react';
import { axe } from 'jest-axe';
import { ToastProvider, useToast } from './toast';
function Trigger() {
const { notify } = useToast();
return <button onClick={() => notify('Saved', 'success')}>Save</button>;
}
test('toast region has no axe violations and announces politely', async () => {
const { container } = render(
<ToastProvider>
<Trigger />
</ToastProvider>,
);
await act(async () => {
screen.getByText('Save').click();
});
expect(screen.getByRole('status')).toHaveTextContent('Saved');
expect(await axe(container)).toHaveNoViolations();
});
test('errors render in an assertive alert region', async () => {
render(<ToastProvider><Trigger /></ToastProvider>);
// ...trigger an error tone, then:
// expect(screen.getByRole('alert')).toHaveTextContent('Connection lost');
});
Screen reader. Test with NVDA + Firefox, VoiceOver + Safari, and at least one mobile reader. Trigger a routine toast while idle and confirm it is spoken; trigger one while typing in a field and confirm it does not interrupt. Trigger an error and confirm it interrupts. For detailed live-region test patterns, see Testing ARIA Live Regions with Jest and Testing Library.
Keyboard. Tab into a visible toast and confirm focus lands on the dismiss button with a visible focus ring; press Enter/Space to dismiss; confirm focus returns sensibly (it should not jump to the top of the page). Hover or focus the region and confirm the auto-dismiss timer pauses.
Common a11y Mistakes
- Mounting the live region and its text simultaneously. The container must pre-exist; otherwise the first toast is often silent. This is the single most common toast failure.
- Using
role="alert"for everything. Assertive interruptions for routine confirmations are hostile and desensitizing. Reserve assertive for errors and time-critical warnings. - Stealing focus. Calling
.focus()on the toast on appearance breaks2.4.3-adjacent expectations and disrupts the user's task. Toasts are status messages, not dialogs. - Icon-only dismiss buttons with no accessible name. A bare
×announces as "button" or nothing useful. Providearia-label. - Auto-dismissing errors, or fixed short timers with no pause. This fails
2.2.1 Timing Adjustable. Make errors persistent and pause routine toasts on hover/focus. aria-atomic="true"on the region. It forces re-announcement of all visible toasts on every change, producing noisy, repetitive speech.
Conclusion
Accessible toasts come down to four decisions: pre-mount the live regions, route by politeness (role="status" vs role="alert"), never move focus, and respect timing with persistent errors plus pause-on-interaction. Each maps to a concrete WCAG criterion—4.1.3 Status Messages and 2.2.1 Timing Adjustable—and each is testable both automatically and by ear. Build the provider once, and every notification in your app inherits correct behavior.
Frequently Asked Questions
Should toast notifications use role="status" or role="alert"?
Use role="status" (polite) for routine, non-urgent feedback like "Saved" or "Copied to clipboard," so the announcement waits until the screen reader is idle and never interrupts the user. Reserve role="alert" (assertive) for errors and time-sensitive warnings the user must hear immediately. Routing every toast through role="alert" desensitizes users to genuine alerts.
Why are my toasts not announced by screen readers even though the role is correct?
Almost always because the live region container is inserted into the DOM at the same moment its text appears. Screen readers register live regions when they enter the accessibility tree; a region created with content in the same render is frequently missed. Render the empty region container at app startup and only mutate its children when a toast fires.
Should a toast receive keyboard focus when it appears?
No. Moving focus to a toast disrupts the user's current task and breaks expectations—a toast is a status message, not a dialog. Keep the toast itself non-focusable and provide a focusable dismiss button with an accessible name. If an action genuinely requires the user's response, use a modal dialog instead of a toast.
How long should a toast stay on screen to satisfy WCAG 2.2.1?
Routine toasts should persist long enough to read—calibrate to message length, with several seconds as a floor—and must pause when the user hovers or focuses the region. Errors and any content requiring action should not auto-dismiss at all; let the user close them. These behaviors together satisfy 2.2.1 Timing Adjustable.