All posts
14 Oct 2026

Building an Accessible Modal Dialog in React: Focus Trapping and Inert Backgrounds

A practical, production-ready guide to building a fully accessible React modal from scratch with focus trapping, ARIA roles, escape key handling, and modern inert background management.

Building an Accessible Modal Dialog in React: Focus Trapping and Inert Backgrounds

Modal dialogs are one of the most common UI patterns in modern web applications. They are used for everything from simple alert confirmations to complex data entry forms. However, they are also one of the most frequently mishandled components when it comes to web accessibility (a11y).

A truly accessible modal requires much more than just a centered <div> with a high z-index and a backdrop. Screen reader users, keyboard-only navigators, and users with low vision all rely on specific behavioral cues to understand and interact with modals effectively.

In this guide, we will build a production-ready, highly accessible modal dialog component from scratch in React using TypeScript. We will cover:

  • Proper semantic HTML and ARIA attributes
  • Keyboard navigation and focus trapping
  • The native HTML inert attribute for background management
  • Restoring focus when the modal closes
  • Managing escape key closures and backdrop clicks

The Core Requirements of an Accessible Modal

Before writing any code, let’s outline the UX and accessibility contract a modal must fulfill:

  1. Semantic Structure: Screen readers must immediately announce the modal’s presence, its title, and its description.
  2. Inert Background: Everything outside the modal must be hidden from screen readers and removed from the keyboard tab order.
  3. Focus Trapping: Keyboard focus must remain trapped inside the modal while it is open. Pressing Tab or Shift + Tab should cycle through the focusable elements within the modal without escaping into the background DOM.
  4. Initial Focus: When the modal opens, focus should immediately move to a logical element inside the modal (such as the close button or the first form input).
  5. Return Focus: When the modal closes, focus must return to the exact element that triggered the modal.
  6. Escape Key Handling: Pressing the Escape key must close the modal.

Step 1: Semantic ARIA Roles and Attributes

Let’s start by designing the skeletal structure of our modal component. We need to use specific ARIA roles to inform assistive technologies about the dialog’s behavior.

tsx
import React, { useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
}

export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
  if (!isOpen) return null;

  return ReactDOM.createPortal(
    <div className="modal-backdrop">
      <div
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        className="modal-content"
      >
        <h2 id="modal-title">{title}</h2>
        <button onClick={onClose} aria-label="Close modal">
          X
        </button>
        <div>{children}</div>
      </div>
    </div>,
    document.body
  );
};

Breaking down the ARIA attributes:

  • role="dialog": Tells screen readers that this element is a dialog window.
  • aria-modal="true": Crucial for instructing assistive technologies to hide elements outside of this dialog (though we will reinforce this with the inert attribute).
  • aria-labelledby="modal-title": Links the dialog to its header so screen readers automatically announce the title when the modal opens.

Step 2: Managing Background Content with the inert Attribute

Historically, hiding background content from screen readers required walking the DOM tree and toggling aria-hidden="true" on every sibling of the modal container. Today, modern browsers support the native HTML inert attribute.

When an element is marked as inert:

  • It and all its descendants are excluded from the accessibility tree.
  • They cannot be focused or clicked.
  • Text selection and searching within the element are disabled.

We can manage this globally by selecting our application’s root element (e.g., #root) and setting its inert property whenever the modal is open.

useEffect(() => {
  if (!isOpen) return;

  const rootElement = document.getElementById('root');
  if (rootElement) {
    rootElement.setAttribute('inert', '');
  }

  return () => {
    if (rootElement) {
      rootElement.removeAttribute('inert');
    }
  };
}, [isOpen]);

This simple approach guarantees that screen reader users and keyboard navigators cannot accidentally tab out of the modal into the background application.


Step 3: Handling Focus Restoration

When a user clicks a “Open Settings” button to trigger a modal, their browser focuses on that button. When the modal closes, accessibility guidelines dictate that focus must return to that exact button so the user doesn’t lose their place on the page.

We can capture the active element when the modal opens and restore it upon unmount:

const previousActiveElementRef = useRef<HTMLElement | null>(null);

useEffect(() => {
  if (isOpen) {
    previousActiveElementRef.current = document.activeElement as HTMLElement;
  } else if (previousActiveElementRef.current) {
    previousActiveElementRef.current.focus();
  }
}, [isOpen]);

Step 4: Implementing Focus Trapping

Focus trapping ensures that pressing Tab while on the last focusable element of a modal wraps focus back to the first focusable element, and vice versa for Shift + Tab.

Let’s write a utility to query all focusable elements inside our modal container:

const FOCUSABLE_SELECTORS = [
  'a[href]',
  'area[href]',
  'input:not([disabled]):not([type="hidden"])',
  'select:not([disabled])',
  'textarea:not([disabled])',
  'button:not([disabled])',
  'iframe',
  'object',
  'embed',
  '[contenteditable]',
  '[tabindex]:not([tabindex="-1"])',
].join(',');

const modalRef = useRef<HTMLDivElement>(null);

useEffect(() => {
  if (!isOpen) return;

  const modalElement = modalRef.current;
  if (!modalElement) return;

  // Automatically focus the first focusable element on open
  const focusableElements = modalElement.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
  const firstElement = focusableElements[0];
  const lastElement = focusableElements[focusableElements.length - 1];

  if (firstElement) {
    firstElement.focus();
  }

  const handleKeyDown = (e: KeyboardEvent) => {
    if (e.key === 'Escape') {
      onClose();
    }

    if (e.key === 'Tab') {
      if (focusableElements.length === 0) {
        e.preventDefault();
        return;
      }

      if (e.shiftKey) {
        // Shift + Tab
        if (document.activeElement === firstElement) {
          e.preventDefault();
          lastElement.focus();
        }
      } else {
        // Tab
        if (document.activeElement === lastElement) {
          e.preventDefault();
          firstElement.focus();
        }
      }
    }
  };

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

Putting It All Together: The Complete Component

Here is the fully assembled, robust, TypeScript-ready Modal component combining portals, ARIA attributes, inert management, focus trapping, and keyboard event handling.

import React, { useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';

interface AccessibleModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
}

const FOCUSABLE_SELECTORS = [
  'a[href]',
  'area[href]',
  'input:not([disabled]):not([type="hidden"])',
  'select:not([disabled])',
  'textarea:not([disabled])',
  'button:not([disabled])',
  'iframe',
  'object',
  'embed',
  '[contenteditable]',
  '[tabindex]:not([tabindex="-1"])',
].join(',');

export const AccessibleModal: React.FC<AccessibleModalProps> = ({
  isOpen,
  onClose,
  title,
  children,
}) => {
  const modalRef = useRef<HTMLDivElement>(null);
  const previousActiveElementRef = useRef<HTMLElement | null>(null);

  // 1. Manage Focus Restoration & Inert Background
  useEffect(() => {
    if (!isOpen) return;

    previousActiveElementRef.current = document.activeElement as HTMLElement;

    const rootElement = document.getElementById('root');
    if (rootElement) {
      rootElement.setAttribute('inert', '');
    }

    return () => {
      if (rootElement) {
        rootElement.removeAttribute('inert');
      }
      if (previousActiveElementRef.current) {
        previousActiveElementRef.current.focus();
      }
    };
  }, [isOpen]);

  // 2. Manage Focus Trapping & Keyboard Events
  useEffect(() => {
    if (!isOpen) return;

    const modalElement = modalRef.current;
    if (!modalElement) return;

    const focusableElements = modalElement.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
    const firstElement = focusableElements[0];
    const lastElement = focusableElements[focusableElements.length - 1];

    if (firstElement) {
      firstElement.focus();
    }

    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Escape') {
        onClose();
      }

      if (e.key === 'Tab') {
        if (focusableElements.length === 0) {
          e.preventDefault();
          return;
        }

        if (e.shiftKey && document.activeElement === firstElement) {
          e.preventDefault();
          lastElement?.focus();
        } else if (!e.shiftKey && document.activeElement === lastElement) {
          e.preventDefault();
          firstElement?.focus();
        }
      }
    };

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

  if (!isOpen) return null;

  return ReactDOM.createPortal(
    <div
      className="modal-backdrop"
      onClick={(e) => {
        if (e.target === e.currentTarget) onClose();
      }}
    >
      <div
        ref={modalRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        className="modal-content"
      >
        <div className="modal-header">
          <h2 id="modal-title">{title}</h2>
          <button onClick={onClose} aria-label="Close modal dialog">
            &times;
          </button>
        </div>
        <div className="modal-body">{children}</div>
      </div>
    </div>,
    document.body
  );
};

Conclusion

Building accessible UI components requires intentional effort, but the payoff is an inclusive web application that works seamlessly for every user, regardless of their input device or assistive technology.

By combining semantic ARIA roles, the native inert attribute, precise focus trapping logic, and proper focus restoration, your React modal dialogs will meet and exceed modern accessibility standards.

More posts