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 useFocusTrap hook tailored for dynamically mounted portaled content
  • Synchronize aria-modal state 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 of inert background 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.
  • inert support or polyfill. Modern evergreen browsers support inert natively; if you target older engines, load the WICG inert polyfill 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.

  1. Event Bubbling Bypass: Naive onKeyDown listeners attached to React components rely on synthetic event propagation. When focus moves via Tab, the browser dispatches native keydown events directly to the focused element, bypassing React's synthetic event tree entirely.
  2. 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, querySelectorAll returns stale or empty node lists, breaking boundary calculations.
  3. Focus Scope Leakage: Without explicit boundary enforcement, Tab naturally 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 useRef to maintain stable references to the portal container and the original trigger element.
  • Attach keydown listeners to document to capture Tab/Shift+Tab before 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" and aria-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 inert on the main application root. inert is preferred over aria-hidden as 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">&times;</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-event tab-cycle test plus a jest-axe assertion in CI so a regression that lets focus escape or removes aria-modal fails the build before merge.
  • Manual: With the modal open, keyboard-only Tab and Shift+Tab all the way around twice; focus must never reach the address bar or background content. Press Escape and 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 onKeyDown instead of native document.addEventListener: Synthetic events do not intercept native Tab navigation, 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+Tab reverse navigation: Implementing only forward tabbing causes focus to jump to the browser chrome or background elements when navigating backwards.
  • Applying aria-modal without hiding background content: aria-modal="true" is a hint to assistive technology; it does not physically prevent focus traversal. Pair it with inert on 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.