Focus Trapping and Element Restoration in React 19
Most accessible React components fall apart on focus restoration. A user clicks a button, a modal opens, they press Escape to close it, and focus drops straight to the top of the body tag. That forces keyboard users to press Tab thirty times just to return to where they were in the document.
In React 19 running inside Next.js 16 App Router client components, managing focus manually requires handling three distinct events: capturing the element that opened the dialog, locking focus inside the modal DOM subtree while it stays open, and returning focus when it closes.
Here's a field-tested dialog component that locks keyboard interaction and returns focus cleanly without external dependencies like focus-trap-react.
import { useEffect, useRef } from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export function Modal({ isOpen, onClose, title, children }: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isOpen) return;
previousFocusRef.current = document.activeElement as HTMLElement;
const modalElement = modalRef.current;
if (!modalElement) return;
const focusableSelector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const focusableElements = modalElement.querySelectorAll<HTMLElement>(focusableSelector);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (firstElement) {
firstElement.focus();
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onClose();
return;
}
if (event.key !== 'Tab') return;
if (event.shiftKey) {
if (document.activeElement === firstElement) {
event.preventDefault();
lastElement?.focus();
}
} else {
if (document.activeElement === lastElement) {
event.preventDefault();
firstElement?.focus();
}
}
}
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
if (previousFocusRef.current) {
previousFocusRef.current.focus();
}
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<div
ref={modalRef}
className="w-full max-w-lg rounded-lg bg-white p-6 shadow-xl"
>
<h2 id="modal-title" className="text-xl font-bold border-b pb-2">
{title}
</h2>
<div className="mt-4">{children}</div>
<button
type="button"
onClick={onClose}
className="mt-6 rounded bg-slate-900 px-4 py-2 text-white hover:bg-slate-800"
>
Close
</button>
</div>
</div>
);
}Notice the cleanup phase inside useEffect. When React 19 unmounts or updates the modal state to closed, focus immediately returns to previousFocusRef.current. If you skip this, screen readers like VoiceOver or NVDA reset their virtual cursor to the <body> element. That breaks user context instantly.
ARIA Attributes That Help (and the Ones That Break Things)
First rule of ARIA: don't use ARIA if a native HTML element provides the behavior out of the box. Placing role="button" on a <div> instead of using a standard <button> element is the most common bug found in web audits. A native button gives you key bindings for Enter and Space, correct focus styling, and form submission semantics for free.
When native elements fall short—such as expandable accordion panels or custom dropdown menus—use targeted ARIA attributes to expose state to accessibility APIs.
Here are the attributes you actually need for interactive UI elements:
- aria-expanded="true|false": Indicates whether a collapsible container or dropdown is currently open.
- aria-controls="element-id": Links the trigger element directly to the container element it toggles.
- aria-haspopup="dialog|menu|listbox": Informs screen readers that activating the button presents a popover layer.
- aria-hidden="true": Hides decorative SVG icons or visual clutter from screen readers so they don't read out random vector paths.
Avoid adding aria-label when visible text is already present in the DOM. If a button says "Save Changes", adding aria-label="Save Changes" is redundant. Worse, if your aria-label differs slightly from visible text (for instance, visible "Delete" with aria-label="Remove item"), voice control tools like MacOS Voice Control will fail when a user tries to speak "Click Delete".
Announcing Dynamic Updates with Live Regions
Next.js 16 client forms frequently submit mutation requests to backend APIs (like a Laravel 12 API running PHP 8.3 returning HTTP status 422 with validation errors). When the server returns an error object, updating state to show text on screen doesn't automatically prompt a screen reader to announce it. Unless the user happens to focus the error container, they'll have no idea the submission failed.
To solve this without throwing focus around, use an ARIA live region. A container marked with aria-live="polite" queues messages until the screen reader finishes speaking its current queue. An aria-live="assertive" region interrupts current speech immediately for critical alerts.
Here's a pattern for form submission errors connected to a Laravel 12 API response:
import { useState } from 'react';
interface FormState {
status: 'idle' | 'loading' | 'success' | 'error';
errorMessage: string | null;
}
export function ProfileForm() {
const [state, setState] = useState<FormState>({
status: 'idle',
errorMessage: null,
});
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setState({ status: 'loading', errorMessage: null });
const formData = new FormData(event.currentTarget);
try {
const response = await fetch('/api/profile', {
method: 'POST',
headers: { 'Accept': 'application/json' },
body: formData,
});
if (!response.ok) {
const payload = await response.json();
const msg = payload.errors?.email?.[0] || payload.message || 'Validation failed.';
setState({ status: 'error', errorMessage: msg });
return;
}
setState({ status: 'success', errorMessage: null });
} catch {
setState({ status: 'error', errorMessage: 'Network error. Please try again.' });
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4 text-left">
<div
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{state.status === 'error' && `Error: ${state.errorMessage}`}
{state.status === 'success' && 'Profile updated successfully.'}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email Address
</label>
<input
id="email"
name="email"
type="email"
required
className="mt-1 block w-full rounded-md border border-gray-300 p-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
{state.status === 'error' && (
<p className="text-sm font-semibold text-red-600" aria-hidden="true">
{state.errorMessage}
</p>
)}
<button
type="submit"
disabled={state.status === 'loading'}
className="rounded bg-indigo-600 px-4 py-2 text-white hover:bg-indigo-700 disabled:opacity-50"
>
{state.status === 'loading' ? 'Saving...' : 'Update Profile'}
</button>
</form>
);
}In this component, the visible error paragraph uses aria-hidden="true" to prevent duplicate announcements, while the visually hidden aria-live="polite" region announces state changes instantly without taking focus away from the input field. That allows the user to correct their input immediately.
Keyboard Testing Workflow That Catches Real Bugs
Automated auditing tools like axe-core or Lighthouse catch roughly 30% of accessibility issues. They find missing alt tags and bad color contrast, but they can't tell you if your tab order makes sense or if focus gets trapped in an infinite loop inside a custom tab bar.
To perform a real keyboard test on your React application, follow this exact checklist without touching your mouse:
1. Clear All Visual Focus Indicators and Test Focus Visibility
Never write CSS like outline: none or outline: 0 without replacing it with an explicit :focus-visible ring. In Tailwind CSS v3 or v4, use classes like focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-indigo-500. Test that every clickable element displays a clear 2px outline when tabbed into.
2. The Tab Focus Order
Press Tab from the top of the browser page to the bottom footer. Verify that:
- Focus moves logically from top to bottom, left to right (matching reading order).
- Skip links appear on the first Tab press to jump over massive header link bars.
- Hidden elements (like off-canvas slideover drawers) don't receive focus while hidden offscreen. Use display: none or visibility: hidden for hidden elements rather than CSS transitions that leave opacity at zero.
3. Custom Dropdowns and Menus
When testing custom select boxes or dropdown menus, standard web expectations require specific keys:
- Space / Enter: Opens the menu and selects highlighted option.
- Up / Down Arrows: Cycles through menu items without shifting browser scroll position.
- Escape: Closes the menu and places focus back on the trigger button.
If pressing the Down Arrow key scrolls the entire page while your dropdown menu is open, you forgot to call event.preventDefault() inside your keydown listener for arrow keys.
Avoid Interactive Elements Built with Divs
When you build custom controls using <div onClick={handleClick}>, you inherit zero accessibility behavior. You have to manually attach a tabIndex={0} attribute, handle both Enter and Space key presses in onKeyDown, and mimic screen reader roles.
Replacing standard buttons with interactive divs inflates your bundle size, increases bug surface area, and degrades user experience. Stick to standard HTML semantic elements first, manage focus explicitly when layers open or close, and keep ARIA minimal.














