Focus Traps and Accessible Modals in React: The Complete Guide
AI generated
</>
{ }
React · Accessibility · ARIA · WCAG
Focus Traps and Accessible Modals in React
Never lose keyboard users inside a dialog

A modal without a focus trap lets keyboard users disappear into the background while the dialog sits visually in front. This guide shows how to implement a custom focus trap in React, set ARIA attributes correctly, and reliably restore focus after closing, all without a ready made library.

18 min read Focus Trap · aria-modal · useRef · Tab handling React 18/19 · WCAG 2.2

1. Why a div overlay alone is not accessible

A typical React modal often starts as a conditionally rendered <div> with a semi transparent backdrop and a card centered on top. Visually it works right away, but not for keyboard users. Without a focus trap, keyboard focus stays wherever it was before opening, often somewhere in the background content that is currently hidden under the overlay surface. Anyone who keeps pressing Tab jumps through links and buttons hidden underneath the dialog, while the actual dialog content remains completely unreachable.

For screen reader users the situation is even less clear. Without role="dialog" and aria-modal="true", assistive technology has no way of knowing that the context just changed. The screen reader happily keeps reading the background content aloud while the dialog sits visually in front. This is exactly the gap between visual and semantic modality that a focus trap in React has to close: focus, ARIA semantics, and visual presentation all need to stay in sync, otherwise the modal simply becomes invisible for part of your audience.

The good news is that a correctly implemented focus trap is not black magic, it is a clearly bounded set of behavior rules that can be encapsulated in a single reusable hook. That is exactly the hook we build step by step in this article, including escape handling, focus restoration, and support for nested dialogs.


// WRONG: modal without any focus management
function NaiveModal({ isOpen, onClose, children }) {
  if (!isOpen) return null;

  return (
    <div className="overlay" onClick={onClose}>
      <div className="card" onClick={(e) => e.stopPropagation()}>
        {children}
        <button onClick={onClose}>Close</button>
      </div>
    </div>
  );
  // Tab still cycles through the background content.
  // Screen readers never announce that a dialog opened.
  // Focus never returns to the trigger button on close.
}

2. How a focus trap fundamentally works

A focus trap consists of three basic behaviors that together create the illusion of a self contained, modal context. First, when the dialog opens, focus is explicitly moved to an element inside the dialog, usually the first interactive element or the dialog container itself. Second, while the dialog is open, keyboard focus stays trapped within the dialog boundaries, Tab and Shift+Tab wrap back to the opposite end of the focusable elements. Third, when the dialog closes, focus is restored exactly to the element that originally opened it.

Technically, the second point relies on determining every focusable element inside the dialog container. This uses a CSS selector that captures buttons, links with an href, form elements, and elements with an explicit tabindex, while excluding disabled or hidden elements. From that list the first and last focusable element are derived, which are decisive for wrapping the tab order. A focus trap that recomputes this list on every keystroke also stays correct with content that loads dynamically inside the dialog.

It is also important to distinguish between a real focus trap and merely dimming the background visually. The latter only protects against mouse clicks, not against keyboard navigation. Only the combination of keyboard event interception and correct ARIA semantics turns a styled overlay into a genuinely modal, accessible dialog in the sense of the WAI-ARIA Authoring Practices.

3. Building a custom useFocusTrap hook

Instead of duplicating focus logic in every component, it makes sense to encapsulate it in a custom hook that can be reused wherever a modal context is needed. The hook accepts a ref to the container, determines the focusable elements on mount, and registers a keydown listener that intercepts Tab jumps at the edges of the list. This pattern works regardless of whether the dialog is rendered through a portal or sits directly in the DOM tree.

One crucial detail: the reference to the previously focused element must be stored before the dialog opens, not only during effect cleanup. Otherwise, the active element in the DOM may already have changed between opening and closing, for example due to a parallel state update. The hook below therefore stores document.activeElement synchronously in the effect callback, before focus is moved into the dialog.


import { useEffect, useRef } from 'react';

const FOCUSABLE_SELECTOR =
  'a[href], button:not([disabled]), textarea:not([disabled]), ' +
  'input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';

function useFocusTrap(containerRef, isActive) {
  const previouslyFocused = useRef(null);

  useEffect(() => {
    if (!isActive || !containerRef.current) return;

    // Remember what was focused before the dialog opened
    previouslyFocused.current = document.activeElement;

    const container = containerRef.current;
    const getFocusable = () =>
      Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR));

    const focusable = getFocusable();
    (focusable[0] || container).focus();

    function handleKeyDown(event) {
      if (event.key !== 'Tab') return;
      const items = getFocusable();
      if (items.length === 0) return;

      const first = items[0];
      const last = items[items.length - 1];

      if (event.shiftKey && document.activeElement === first) {
        event.preventDefault();
        last.focus();
      } else if (!event.shiftKey && document.activeElement === last) {
        event.preventDefault();
        first.focus();
      }
    }

    container.addEventListener('keydown', handleKeyDown);
    return () => {
      container.removeEventListener('keydown', handleKeyDown);
      // Return focus to the element that opened the dialog
      previouslyFocused.current?.focus();
    };
  }, [isActive, containerRef]);
}

export default useFocusTrap;

4. Setting the correct ARIA attributes on the dialog

The focus trap alone is not enough if the dialog lacks its semantic role. role="dialog" tells assistive technology that a self contained interaction context has appeared. aria-modal="true" additionally signals that the rest of the page content is not relevant for the duration of the interaction, many screen readers then automatically hide the background from virtual navigation. aria-labelledby points to the id of the dialog heading, aria-describedby optionally points to an explanatory description underneath.

A commonly overlooked point: these attributes must sit on the dialog container itself, not on the overlay wrapper above it. If aria-modal is accidentally set on the outer overlay div while the actual content lives in a nested container, some screen readers misinterpret the modality. The title should also be a real heading element, not a plain <div> with large text, so that the heading level shows up correctly in landmark navigation.


function Dialog({ titleId, descriptionId, onClose, children }) {
  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby={titleId}
      aria-describedby={descriptionId}
      className="dialog-card"
    >
      <strong id={titleId}>Confirm action</strong>
      <p id={descriptionId}>
        This action cannot be undone.
      </p>
      {children}
      <button type="button" onClick={onClose} aria-label="Close dialog">
        ×
      </button>
    </div>
  );
}

5. Escape key, overlay click, and focus restoration

Besides the tab trap, closing with the Escape key is one of the fixed expectations for any modal dialog pattern. The listener for this can be registered in the same hook or in a second, small hook that works independently from the tab logic. It matters to register the listener on document rather than only on the dialog container, since focus could theoretically also sit on an element outside the visible container, for example in portal based implementations with multiple DOM roots.

Clicking the overlay area outside the dialog card is an additional but optional way to close, benefiting only mouse and touch users. For keyboard users, Escape remains the primary path. Restoring focus to the triggering element is the step most often forgotten because it does not stand out visually. Without it, focus jumps back to <body> after closing, and the keyboard user has to tab through the entire page from the top again just to get back to where they were before opening the dialog.


function useEscapeToClose(onClose, isActive) {
  useEffect(() => {
    if (!isActive) return;

    function handleKeyDown(event) {
      if (event.key === 'Escape') onClose();
    }

    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [isActive, onClose]);
}

// Usage inside the Modal component
function Modal({ isOpen, onClose, children }) {
  const containerRef = useRef(null);
  useFocusTrap(containerRef, isOpen);
  useEscapeToClose(onClose, isOpen);

  if (!isOpen) return null;

  return (
    <div className="overlay" onMouseDown={onClose}>
      <div ref={containerRef} onMouseDown={(e) => e.stopPropagation()}>
        {children}
      </div>
    </div>
  );
}

6. Nested modals and the focus stack

As soon as one dialog can open a second, nested dialog, for example a confirmation prompt on top of a form modal, a single previouslyFocused value is no longer enough. Every dialog level needs its own reference to the previously focused element, and those references have to be treated like a stack: the innermost dialog returns focus to the middle one on close, which in turn returns it to the original trigger element.

In practice this can be solved by giving each dialog instance its own useFocusTrap call with its own ref, while React ensures that only the most recently opened, that is the topmost, dialog actively reacts to keyboard events. A simple approach is a global counter or context that tracks the depth of currently open dialogs and disables keydown listeners of older levels while a younger level is active. Without this coordination, multiple focus traps react to Tab at the same time, producing unpredictable jump behavior.

7. Building it yourself versus using a library

A hand built focus trap pays off for smaller projects and for gaining a deep understanding of the mechanics, but it has limits. Edge cases such as iframes inside the dialog, dynamically loaded web components, or elements with a negative tabindex that are still programmatically focusable require extra code that established libraries already cover. React Aria from Adobe and the focus trap implementation in Radix UI encapsulate exactly these edge cases and are regularly tested against real screen readers.

For projects already building a design system on top of Radix UI or React Aria, reusing the built in focus management is usually the more pragmatic choice, because it works consistently with other components in the system. Building it yourself remains valuable for situations with very specific requirements, for example dialogs in canvas based editors where the focusable area does not match the classic DOM tree and generic libraries hit their limits.

8. Testing focus traps automatically

Automated tests for a focus trap check two things: the correct tab order inside the dialog and focus restoration after closing. With React Testing Library and @testing-library/user-event, tab navigation can be simulated realistically, including Shift+Tab at the start of the list. In addition, jest-axe automatically checks whether role="dialog", aria-modal, and the label references are set correctly, without having to manually look up every attribute name in the test.


import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import Modal from './Modal';

test('traps Tab focus inside the dialog', async () => {
  const user = userEvent.setup();
  render(
    <Modal isOpen onClose={() => {}}>
      <button>First</button>
      <button>Last</button>
    </Modal>
  );

  const first = screen.getByText('First');
  const last = screen.getByText('Last');

  expect(first).toHaveFocus();
  await user.tab();
  expect(last).toHaveFocus();
  await user.tab(); // wraps back to the first element
  expect(first).toHaveFocus();
});

test('has no accessibility violations', async () => {
  const { container } = render(
    <Modal isOpen onClose={() => {}}>
      <button>Confirm</button>
    </Modal>
  );
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

9. Focus trap approaches compared

To decide between building it yourself, a generic library, and a full headless component kit, it helps to compare the most important properties directly. A focus trap in React can be implemented at several levels, with different effort and different coverage of edge cases.

Approach Effort Edge case coverage Recommendation
Custom useFocusTrap hook Medium Low to medium Small projects, learning value
focus-trap-react Low High Existing components without a design system
React Aria (Adobe) Low Very high New design systems
Radix UI Dialog Low Very high Tailwind based component libraries
Native <dialog> element Very low Medium (browser dependent) Simple, short lived dialogs

The native <dialog> element has shipped with a built in focus trap for some years now when opened via showModal(), though with browser dependent differences for edge cases such as nested forms. For complex React applications with their own design system, a well tested library remains the lower risk choice, while building it yourself makes the most sense where full control over every detail is required.

Mironsoft

React development with a focus on accessibility and design systems

Modals that work for every user?

We build and audit React components with a clean focus trap, correct ARIA roles, and tested keyboard operation, so your application stays WCAG compliant.

Accessibility audit

Check existing modals and dialogs for focus trap and ARIA compliance

Component refactoring

Cleanly integrate custom hooks or Radix/React Aria into your existing design system

Test automation

Integrate jest-axe and Testing Library into your CI pipeline

10. Summary

A clean focus trap in React consists of three interacting parts: initial focus on open, the focus trap during interaction, and focus restoration on close. The ARIA part with role="dialog", aria-modal, and aria-labelledby ensures that screen readers perceive the same modality that is already visible on screen. Escape handling and correct management of nested dialogs round out a complete, accessible modal pattern.

For most projects with an existing design system, a well tested library such as Radix UI or React Aria is the lower risk choice, because edge cases like iframes or dynamic content are already covered. Building a focus trap yourself remains instructive and, in special cases with very specific requirements, the only practical solution. Automated tests with Testing Library and jest-axe make sure accessibility survives future changes too.

Focus Traps and Accessible Modals in React — the essentials at a glance

Focus cycle

Set focus on open, trap it inside the dialog with Tab/Shift+Tab, restore it precisely on close.

ARIA semantics

role="dialog", aria-modal="true", and aria-labelledby make modality visible to screen readers.

Close mechanisms

Escape key as the primary path, overlay click as an optional addition for mouse and touch users.

Library or build it yourself

Radix UI and React Aria cover edge cases, building it yourself pays off for very specific requirements.

11. FAQ: Focus Traps and Accessible Modals in React

1What exactly is a focus trap in React?
Keeps keyboard focus contained in the open dialog, moves it in initially, and returns it to the triggering element on close.
2Is aria-modal enough without a focus trap?
No, it only informs screen readers about modality but does not prevent tabbing into the background. Both are needed together.
3Why store the focus reference before opening?
document.activeElement can change before cleanup runs. Reading it late may return the wrong element.
4How does this work with nested modals?
Each level needs its own reference following a stack principle. Only the topmost level actively reacts to Tab.
5Build it myself or use a library?
Radix UI or React Aria for standard cases, a custom build for very specific requirements or learning purposes.
6How do I test a focus trap automatically?
Testing Library with user-event for tab simulation, jest-axe for automated ARIA checks.
7No focusable element in the dialog?
Set focus on the container itself, which then needs tabindex=-1.
8Does dialog with showModal solve this?
Largely yes in modern browsers, edge cases with nested forms still need verification.
9Does aria-describedby always need to be set?
Only with an actual description, aria-labelledby for the title is nearly always a good idea though.
10Does a focus trap cost performance?
Barely, just a few event listeners and one DOM query on open, negligible compared to the rest of the rendering.