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
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.
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 todocumentor the container to 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.
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);
const appRootRef = useRef<HTMLElement | null>(typeof document !== 'undefined' ? document.getElementById('app-root') : null);
useFocusTrap(isOpen, containerRef);
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
appRootRef.current?.setAttribute('inert', '');
} else {
document.body.style.overflow = '';
appRootRef.current?.removeAttribute('inert');
}
return () => {
document.body.style.overflow = '';
appRootRef.current?.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
);
}
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(<AccessibleModal isOpen={true} onClose={jest.fn()}><button>Confirm</button></AccessibleModal>);
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();
expect(screen.getByRole('dialog')).toHaveFocus(); // Wraps to first element
});
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(<AccessibleModal isOpen={true} onClose={jest.fn()}>Content</AccessibleModal>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
CI/CD Pipeline Configuration
Add a dedicated accessibility validation step to your GitHub Actions or GitLab CI workflow.
# .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.
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.
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 the portal container 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.