react nextjs accessibility patterns
Fixing Focus Trap Issues in React Portals
When rendering modals, dialogs, or dropdowns outside the primary DOM tree, implementing predictable keyboard navigation requires strict adherence to established React & Next.js Accessibility Patterns. This guide addresses the architectural failure of focus escaping the viewport when using createPortal. We will implement a robust, WCAG-compliant focus trap that handles dynamic DOM mounting, native event delegation, and React's concurrent rendering lifecycle.
WCAG Success Criteria Addressed
- 2.4.3 Focus Order (Level A): Ensures logical navigation sequence remains intact when focus enters portaled content.
- 2.4.7 Focus Visible (Level AA): Maintains visible focus indicators during trap cycling.
- 1.3.2 Meaningful Sequence (Level A): Guarantees assistive technology reads content in the correct DOM order.
Core Implementation Objectives
- Decouple React's synthetic event system from native DOM focus propagation
- Implement a custom
useFocusTraphook tailored for dynamically mounted portaled content - Synchronize
aria-modalstate with inert background content suppression - Validate focus boundaries using screen readers and keyboard-only navigation workflows
Prerequisites
Before implementing the trap, ensure your project meets these baseline conditions. Missing any of them produces intermittent failures that are difficult to reproduce:
- React 18+ with an understanding of concurrent rendering and StrictMode's intentional double-invocation in development.
- A dedicated portal mount node (for example
<div id="modal-root">) rendered in your document shell, outside the application root. Mounting portals into the app root defeats the purpose ofinertbackground suppression. - A stable trigger element. The element that opens the overlay must remain in the DOM while the overlay is open so focus can return to it on close.
inertsupport or polyfill. Modern evergreen browsers supportinertnatively; if you target older engines, load the WICGinertpolyfill before mounting overlays.
Why Focus Traps Fail in React Portals
React portals render children into a detached DOM node while preserving component context. This architectural split creates a fundamental mismatch between React's synthetic event delegation and native browser focus management.
- Event Bubbling Bypass: Naive
onKeyDownlisteners attached to React components rely on synthetic event propagation. When focus moves viaTab, the browser dispatches nativekeydownevents directly to the focused element, bypassing React's synthetic event tree entirely. - Concurrent Rendering Race Conditions: React 18's concurrent features can interrupt mount/unmount cycles. If a trap initializes before the portal DOM is fully committed,
querySelectorAllreturns stale or empty node lists, breaking boundary calculations. - Focus Scope Leakage: Without explicit boundary enforcement,
Tabnaturally progresses to the next focusable element in the document order, which often resides in the background application layer.
The deeper architectural lesson is that a portal moves the DOM position of content but not its React position. Your component still sits inside the parent tree for state and context purposes, yet keyboard and focus behavior follow the physical DOM. Any solution must therefore reason about the real DOM node, never the component hierarchy. For the broader pattern of isolating these imperative concerns into reusable logic, see React Hooks for Accessibility and the foundational guidance in Focus management strategies for SPAs.
Debugging Workflow: Open Chrome DevTools → Elements panel. Locate the portal container (#modal-root). Verify that keydown listeners are attached directly to the portal node via Event Listeners tab, not to the React root. Use the focus() console method to manually trigger boundary checks during active development.
Building a Robust useFocusTrap Hook
To guarantee reliable focus containment, bypass React's synthetic event system and attach native listeners directly to the portal container. This pattern aligns with proven React Hooks for Accessibility architectures.
Implementation Requirements
- Use
useRefto maintain stable references to the portal container and the original trigger element. - Attach
keydownlisteners todocumentto captureTab/Shift+Tabbefore they propagate. - Implement explicit
preventDefault()logic to override default browser tabbing behavior at boundaries. - Safely restore focus to the trigger element during unmount to prevent focus loss.
import { useEffect, useRef, useCallback } from 'react';
export function useFocusTrap(isOpen: boolean, containerRef: React.RefObject<HTMLElement>) {
const triggerRef = useRef<HTMLElement | null>(null);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (!isOpen || !containerRef.current) return;
const container = containerRef.current;
const focusable = container.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusable.length === 0) return;
const firstEl = focusable[0];
const lastEl = focusable[focusable.length - 1];
if (e.key === 'Tab') {
if (e.shiftKey) {
if (document.activeElement === firstEl) {
e.preventDefault();
lastEl.focus();
}
} else {
if (document.activeElement === lastEl) {
e.preventDefault();
firstEl.focus();
}
}
}
}, [isOpen, containerRef]);
useEffect(() => {
if (isOpen) {
triggerRef.current = document.activeElement as HTMLElement;
document.addEventListener('keydown', handleKeyDown);
// Delay focus to ensure DOM commit in concurrent mode
requestAnimationFrame(() => containerRef.current?.focus());
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
triggerRef.current?.focus();
};
}, [isOpen, handleKeyDown, containerRef]);
}
Validation Step: Test with sequential Tab, reverse Shift+Tab, and Escape key presses. Verify that document.activeElement never resolves to document.body or any element outside the portal container.
Handling Dynamically Added Focusable Elements
The hook above queries focusable elements on every keystroke, which is deliberate: a portal whose contents change — a multi-step wizard, an async-loaded form, a collapsible section — would break a trap that cached its boundary nodes once at mount. Re-querying inside handleKeyDown keeps the first and last boundaries accurate as the subtree mutates.
If you need to recompute boundaries outside of a keystroke (for example, to move focus into newly revealed content), observe the container with a MutationObserver and re-run your focus logic when childList changes settle. Keep the observer scoped to the portal node to avoid reacting to unrelated document mutations, and disconnect it in the hook's cleanup to prevent leaks across route transitions.
Integrating with createPortal and aria-modal
Correct component composition ensures screen readers announce the modal correctly while strictly trapping focus. The trap hook manages keyboard navigation; the component wrapper manages semantic state and background suppression.
Implementation Requirements
- Apply
role="dialog"andaria-modal="true"to the portal wrapper. - Set
tabIndex={-1}to make the container programmatically focusable without adding it to the natural tab order. - Suppress background content using
inerton the main application root.inertis preferred overaria-hiddenas it natively prevents focus traversal and click events. - Enforce strict state cleanup on unmount to restore page scroll and interactivity.
import { useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useFocusTrap } from './useFocusTrap';
interface AccessibleModalProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}
export function AccessibleModal({ isOpen, onClose, children }: AccessibleModalProps) {
const portalRoot = typeof document !== 'undefined' ? document.getElementById('modal-root') : null;
const containerRef = useRef<HTMLDivElement>(null);
useFocusTrap(isOpen, containerRef);
useEffect(() => {
const appRoot = document.getElementById('app-root');
if (isOpen) {
document.body.style.overflow = 'hidden';
appRoot?.setAttribute('inert', '');
} else {
document.body.style.overflow = '';
appRoot?.removeAttribute('inert');
}
return () => {
document.body.style.overflow = '';
appRoot?.removeAttribute('inert');
};
}, [isOpen]);
if (!isOpen || !portalRoot) return null;
return createPortal(
<div
ref={containerRef}
role="dialog"
aria-modal="true"
tabIndex={-1}
style={{ position: 'fixed', inset: 0, zIndex: 9999 }}
>
<button onClick={onClose} aria-label="Close modal">×</button>
{children}
</div>,
portalRoot
);
}
For Next.js App Router projects where the modal is rendered from a Server Component boundary, the same trapping rules apply, but mounting and inert toggling must run inside a 'use client' wrapper. See Handling accessible modals in Next.js 14 Server Components for the server/client split.
Validation Step: Execute VoiceOver (macOS) or NVDA (Windows). Verify the screen reader announces "Dialog" upon open, ignores background content, and correctly announces the close button. Confirm focus returns to the original trigger element on onClose.
Automated and Manual Testing Strategies
Reproducible testing workflows prevent regression in CI/CD pipelines and guarantee compliance across framework updates.
Unit & Integration Testing
Use @testing-library/react and @testing-library/user-event to simulate sequential keyboard navigation. Assert boundary containment programmatically.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AccessibleModal } from './AccessibleModal';
test('focus remains trapped within modal', async () => {
const user = userEvent.setup();
render(
<div>
<div id="modal-root" />
<AccessibleModal isOpen={true} onClose={jest.fn()}>
<button>Confirm</button>
</AccessibleModal>
</div>
);
const modal = screen.getByRole('dialog');
expect(modal).toHaveFocus();
await user.tab();
expect(screen.getByRole('button', { name: 'Confirm' })).toHaveFocus();
await user.tab();
expect(screen.getByRole('button', { name: 'Close modal' })).toHaveFocus();
await user.tab();
// Focus wraps back to dialog container (tabIndex={-1})
expect(modal).toHaveFocus();
});
Static ARIA Validation
Integrate jest-axe into your test suite to catch missing roles, incorrect aria-modal states, and invalid focusable elements before deployment.
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('modal passes axe accessibility checks', async () => {
const { container } = render(
<div>
<div id="modal-root" />
<AccessibleModal isOpen={true} onClose={jest.fn()}>Content</AccessibleModal>
</div>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
CI/CD Pipeline Configuration
# .github/workflows/a11y-ci.yml
name: Accessibility Validation
on: [push, pull_request]
jobs:
a11y-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npm run test:unit -- --coverage
- run: npx playwright test --config=playwright-a11y.config.ts
- name: Upload Axe Reports
if: always()
uses: actions/upload-artifact@v4
with: { name: a11y-reports, path: ./test-results/ }
Validation Step: Run the CI pipeline locally using npm run test:unit. Validate against WCAG 2.2 success criteria using automated tools and manual screen reader passes across Chrome, Firefox, and Safari.
How to Verify
Confirm the trap works using both an automated gate and a manual pass:
- Automated: Run the
@testing-library/user-eventtab-cycle test plus ajest-axeassertion in CI so a regression that lets focus escape or removesaria-modalfails the build before merge. - Manual: With the modal open, keyboard-only
TabandShift+Taball the way around twice; focus must never reach the address bar or background content. PressEscapeand confirm focus lands back on the original trigger. Repeat with NVDA and VoiceOver active to confirm the dialog role is announced and the background is silent.
Common Pitfalls
- Relying on React's
onKeyDowninstead of nativedocument.addEventListener: Synthetic events do not intercept nativeTabnavigation, allowing focus to leak outside the portal. - Forgetting to restore focus to the original trigger element: Closing a modal without returning focus leaves keyboard users stranded at the top of the document or in an undefined state.
- Using
tabindex="0"on non-interactive elements: Forces non-focusable elements into the tab order, violating semantic HTML principles and confusing screen readers. - Neglecting
Shift+Tabreverse navigation: Implementing only forward tabbing causes focus to jump to the browser chrome or background elements when navigating backwards. - Applying
aria-modalwithout hiding background content:aria-modal="true"is a hint to assistive technology; it does not physically prevent focus traversal. Pair it withinerton the main app container for deterministic behavior. - Caching focusable nodes at mount: Boundaries computed once break when the portal's contents change. Re-query on each keystroke or observe mutations.
Conclusion
A reliable portal focus trap rests on three commitments: attach native keydown listeners to capture real browser tabbing, suppress the background with inert alongside aria-modal, and always restore focus to the trigger on unmount. Encapsulating this in a useFocusTrap hook keeps the contract testable and reusable across every overlay in your application. Pair the hook with the automated and manual verification steps above, and your modals will satisfy 2.4.3, 2.4.7, and 1.3.2 across browsers and assistive technologies.
Frequently Asked Questions
Why does focus escape my React modal when using createPortal?
React portals render DOM nodes outside the parent component tree, which breaks standard synthetic event bubbling. Native focus events do not respect React's synthetic event system, requiring manual keydown listeners attached directly to document to trap focus effectively.
Should I use aria-modal or inert for background content?
Use both for maximum compatibility. aria-modal="true" instructs screen readers to treat the dialog as the sole interactive context, while applying inert to the main app container physically prevents focus traversal and click events from reaching background elements. This dual approach ensures deterministic compliance across different assistive technologies.
How do I test focus traps in automated CI/CD pipelines?
Use @testing-library/user-event to simulate Tab and Shift+Tab sequences, then assert that document.activeElement remains within the expected container boundaries. Combine this with axe-core for static ARIA validation to catch missing labels, incorrect roles, or invalid focusable elements before deployment.
How do I keep the trap working when the modal content changes after mount?
Re-query focusable elements on each keystroke rather than caching them at mount, and use a scoped MutationObserver if you must recompute boundaries between keystrokes. This keeps the first and last boundary nodes accurate as wizards, async forms, or collapsible sections mutate the subtree.
Does requestAnimationFrame actually matter for setting initial focus?
Yes. In React 18 concurrent mode the portal node may not be fully committed when the effect runs. Deferring the initial focus() call to the next frame ensures the container exists in the DOM, preventing the focus call from silently no-op'ing.