react nextjs accessibility patterns
Building a useAnnouncer Hook for Live Regions
Scatter aria-live regions across a codebase and you inherit a class of bugs that never show up in a visual diff: identical messages that fail to re-announce, rapid updates that clobber each other, regions mounted too late to register, and duplicate regions that make screen readers stutter. The fix is to centralize. One useAnnouncer hook, backed by a single provider that mounts exactly one polite region and one assertive region at the app root, gives every component a clean announce(message, { assertive }) call and makes correct behavior the default. This guide builds that hook end to end, covering message clearing, queueing, and SSR safety. It belongs to the React Hooks for Accessibility and the wider React & Next.js Accessibility Patterns set, and it implements WCAG 4.1.3 Status Messages.
Prerequisites
- React 18+. The queue uses functional state updates and effects; SSR safety assumes the App Router or any framework that renders on the server.
- A single provider mounted at the root (Next.js
app/layout.tsx, or the top of your component tree). The whole point is that the live regions exist once and from first paint. - A
.sr-onlyutility class that hides content visually while keeping it in the accessibility tree (use thecliptechnique, neverdisplay:none, which removes it from the tree). - Understanding that two regions are needed:
aria-live="polite"for routine messages andaria-live="assertive"for urgent ones. A single region cannot be both.
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
Why a Single Hook Beats Ad-Hoc Regions
Three recurring failures motivate the design:
- Duplicate-message silence. Setting a live region's text to the same string twice in a row produces no DOM mutation, so the screen reader stays silent. A user who triggers "Item added to cart" twice hears it once. The hook must force a change.
- Clobbering. Two announcements fired within the same render cycle overwrite each other; the screen reader only ever speaks the last one. The hook must queue.
- Late mounting. A region created at the moment of the first announcement is often missed by assistive technology, which registers live regions when they enter the tree. The hook's provider must mount the regions up front and leave them empty.
A centralized provider plus a thin hook solves all three in one place, so individual components never reason about live-region mechanics.
The Provider and the Hook
The provider holds two queues, drains them one message at a time, and clears each region before writing the next message so identical consecutive strings still register as a mutation. It exposes announce through context.
'use client';
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from 'react';
interface AnnounceOptions {
assertive?: boolean;
}
interface AnnouncerApi {
announce: (message: string, options?: AnnounceOptions) => void;
}
const AnnouncerContext = createContext<AnnouncerApi | null>(null);
export function AnnouncerProvider({ children }: { children: ReactNode }) {
const [polite, setPolite] = useState('');
const [assertive, setAssertive] = useState('');
// Refs hold the pending queues so enqueueing never depends on render timing.
const politeQueue = useRef<string[]>([]);
const assertiveQueue = useRef<string[]>([]);
const draining = useRef({ polite: false, assertive: false });
const drain = useCallback((channel: 'polite' | 'assertive') => {
if (draining.current[channel]) return;
const queue = channel === 'polite' ? politeQueue : assertiveQueue;
const setText = channel === 'polite' ? setPolite : setAssertive;
const next = queue.current.shift();
if (next === undefined) return;
draining.current[channel] = true;
// Clear first so an identical next message is still a DOM change (4.1.3).
setText('');
requestAnimationFrame(() => {
setText(next);
// Give AT time to read before the next item; tune to taste.
setTimeout(() => {
draining.current[channel] = false;
drain(channel); // Pull the next queued message, if any.
}, 150);
});
}, []);
const announce = useCallback(
(message: string, options?: AnnounceOptions) => {
if (!message) return;
const channel = options?.assertive ? 'assertive' : 'polite';
(channel === 'polite' ? politeQueue : assertiveQueue).current.push(message);
drain(channel);
},
[drain],
);
return (
<AnnouncerContext.Provider value={{ announce }}>
{children}
{/* Exactly one polite and one assertive region, mounted once, empty. */}
<div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{polite}
</div>
<div role="alert" aria-live="assertive" aria-atomic="true" className="sr-only">
{assertive}
</div>
</AnnouncerContext.Provider>
);
}
export function useAnnouncer(): AnnouncerApi {
const ctx = useContext(AnnouncerContext);
if (!ctx) {
throw new Error('useAnnouncer must be used within an AnnouncerProvider');
}
return ctx;
}
Two design points carry the weight. Clearing before setting (setText('') then a framed setText(next)) guarantees a DOM mutation even when the same string is announced twice, fixing the duplicate-message silence. The ref-backed FIFO queue serializes rapid calls so a burst of announcements is read in order instead of clobbering down to the last one. The brief setTimeout between drains gives screen readers room to finish speaking; calibrate it to your message lengths.
SSR Safety
The provider is a client component ('use client') and reads no browser globals during render—requestAnimationFrame and setTimeout only run inside effects/callbacks, never at module or render time, so there is nothing to crash the server pass. The two live-region divs render identically on server and client, which means no hydration mismatch: they serialize as empty regions and the client takes over with empty state. Mount the provider once at the App Router root:
// app/layout.tsx
import { AnnouncerProvider } from '@/components/announcer';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{/* One provider wraps everything; regions exist from first paint. */}
<AnnouncerProvider>{children}</AnnouncerProvider>
</body>
</html>
);
}
Consuming it anywhere is a one-liner:
'use client';
import { useAnnouncer } from '@/components/announcer';
export function SaveButton() {
const { announce } = useAnnouncer();
return (
<button
onClick={async () => {
await saveDraft();
announce('Draft saved'); // Polite by default.
}}
>
Save
</button>
);
}
// For errors, opt into the assertive channel:
// announce('Save failed—check your connection', { assertive: true });
How to Verify
axe / jest-axe. Confirm both regions exist with correct roles and that a queued message reaches the DOM. Use fake timers to advance past the drain delay:
import { render, screen, act } from '@testing-library/react';
import { axe } from 'jest-axe';
import { AnnouncerProvider, useAnnouncer } from './announcer';
function Probe() {
const { announce } = useAnnouncer();
return <button onClick={() => announce('Item added')}>Add</button>;
}
test('announces queued message with no axe violations', async () => {
const { container } = render(
<AnnouncerProvider>
<Probe />
</AnnouncerProvider>,
);
await act(async () => {
screen.getByText('Add').click();
});
// requestAnimationFrame + timer flush, then assert:
expect(screen.getByRole('status')).toBeInTheDocument();
expect(screen.getByRole('alert')).toBeInTheDocument();
expect(await axe(container)).toHaveNoViolations();
});
For comprehensive queue and clearing assertions—including identical-message re-announcement and timer control—follow the patterns in Testing ARIA Live Regions with Jest and Testing Library.
Screen reader. With NVDA + Firefox and VoiceOver + Safari: trigger the same message twice and confirm it is spoken both times (proves clearing works); fire three messages in quick succession and confirm all three are read in order (proves queueing); fire an assertive message and confirm it interrupts.
Keyboard. The hook itself moves no focus, which is correct—announcements must never steal focus. Confirm that triggering an announcement from a button leaves focus on that button and does not disrupt an in-progress task, consistent with 4.1.3 Status Messages.
Common a11y Mistakes
- Rendering regions only when there is a message. Conditionally mounting the region means it is registered too late and the first announcement is missed. Always render both regions; toggle only their text.
- Skipping the clear step. Writing the same string twice produces no mutation and no speech. Clear, then set on the next frame.
- No queue. Rapid announcements overwrite each other; only the last survives. Serialize through a FIFO queue.
- Two regions for one purpose. Multiple polite regions cause stuttering and unpredictable ordering. Centralize to exactly one polite and one assertive region.
- Defaulting to assertive. Routine status should be polite; assertive interrupts and desensitizes users. Make
assertivean explicit opt-in. - Touching
windowduring render. Reading browser globals at render or module scope breaks SSR. Keep all timing in effects and callbacks.
Conclusion
A useAnnouncer hook turns live-region correctness into a single, testable concern. Mount one polite and one assertive region at the root, queue messages to prevent clobbering, clear before each set so duplicates still announce, and keep every browser call inside effects for SSR safety. Components then announce state changes with one line and inherit 4.1.3 Status Messages compliance for free.
Frequently Asked Questions
Why doesn't my screen reader announce the same message twice in a row?
Setting a live region's text to a string identical to its current value produces no DOM mutation, so assistive technology has nothing to detect and stays silent. Fix it by clearing the region to an empty string first, then writing the message back on the next animation frame. The empty-to-string transition is a real change, so the message is announced even when it repeats.
Why use a single provider instead of placing live regions in each component?
Multiple live regions of the same politeness cause stuttering, unpredictable ordering, and missed announcements, and each scattered region risks being mounted too late to register. One provider mounting exactly one polite and one assertive region at the root—exposed through a useAnnouncer hook—gives every component a single, ordered, reliable channel and removes live-region mechanics from feature code.
How does the hook handle several announcements fired at once?
It pushes each message onto a FIFO queue held in a ref and drains them one at a time, clearing the region and setting the next message after a short delay. Without this serialization, announcements made in the same render cycle overwrite one another and only the last is spoken. The queue guarantees a burst of updates is read in full and in order.
Is the useAnnouncer hook safe for server-side rendering?
Yes. The provider is a client component and reads no browser globals during render—requestAnimationFrame and setTimeout run only inside effects and event callbacks. Both live regions render as empty on the server and hydrate to empty state on the client, so there is no hydration mismatch. Mount the provider once at your App Router root layout.