All posts
18 Oct 2026

Building an Accessible Modal in React: Focus Trapping, Inert Backgrounds, and Escape Key Handling

A comprehensive technical guide on building a bulletproof, accessible React modal from scratch with TypeScript, covering focus trapping, inert backgrounds, and keyboard navigation.

Building an Accessible Modal in React: Focus Trapping, Inert Backgrounds, and Escape Key Handling

Modals are one of the most common UI patterns in modern web applications. Yet, they are also one of the most frequently mishandled components when it comes to web accessibility (a11y).

If you have ever opened a modal, pressed the Tab key a few times, and watched your focus slip out into the background content behind an invisible backdrop, you’ve witnessed an accessibility failure. Screen reader users face even worse issues: background content can remain exposed to assistive technologies, causing utter confusion.

In this technical walkthrough, we will build a bulletproof, accessible React modal component from scratch using TypeScript. We will cover:

  1. Focus Trapping: Keeping keyboard focus strictly inside the modal.
  2. The inert Attribute: Silencing background content for screen readers and pointer devices alike.
  3. Escape Key Handling: Dismissing the modal gracefully via keyboard.
  4. Focus Restoration: Returning focus to the trigger element when the modal closes.

The Anatomy of an Accessible Modal

Before writing code, let’s establish the requirements mandated by the WAI-ARIA Authoring Practices Guide (APG) for Dialog Modal patterns:

  • Role: The element must have role="dialog" and aria-modal="true".
  • Labeling: It must have an accessible name via aria-labelledby or aria-label.
  • Focus Management: When the modal opens, focus must move to an element inside the modal. While open, focus must be trapped. When closed, focus must return to the element that triggered it.
  • Interactivity: Pressing Escape must close the modal.

Step 1: Setting up the Component and Portal

To prevent CSS stacking context (z-index) and layout containment issues, our modal should render into a DOM node outside the main application root using React Portals.

tsx
import React, { useEffect, useRef } from 'react';
import { createPortal } 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 createPortal(
    <div className="modal-overlay">
      <div
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        className="modal-content"
      >
        <h2 id="modal-title">{title}</h2>
        {children}
        <button onClick={onClose} aria-label="Close modal">
          Close
        </button>
      </div>
    </div>,
    document.body
  );
};

Step 2: Focus Restoration and Trigger Tracking

When a user clicks a “Open Settings” button, the modal appears. When they close it, their keyboard focus must return to that exact “Open Settings” button. If focus drops back to document.body, keyboard and screen reader users lose their place on the page.

We achieve this by capturing document.activeElement right before the modal opens, and restoring it when the modal unmounts.

// Inside Modal component
const previousActiveElementRef = useRef<HTMLElement | null>(null);

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

Step 3: Implementing the Focus Trap

When a modal is open, pressing Tab must cycle through the focusable elements inside the modal and never escape to the document body. Pressing Shift + Tab should cycle backwards.

To build this, we query all focusable elements within our modal container and intercept the keydown event.

const modalRef = useRef<HTMLDivElement>(null);

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

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

  // Query all focusable elements
  const focusableSelectors = [
    'a[href]',
    'area[href]',
    'input:not([disabled])',
    'select:not([disabled])',
    'textarea:not([disabled])',
    'button:not([disabled])',
    'iframe',
    'object',
    'embed',
    '[contenteditable]',
    '[tabindex]:not([tabindex="-1"])',
].join(',');

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

  // Automatically focus the first element on open
  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]);

Step 4: Making the Background inert

Historically, developers relied on aria-hidden="true" applied to the root application container to hide background content from screen readers. However, this approach had caveats: sighted keyboard or mouse users could still interact with background content unless custom click-eaters and tab-indexes were managed.

Today, modern browsers support the HTML inert attribute. When an element is marked as inert, the browser:

  • Hides the element and all its descendants from accessibility trees (screen readers).
  • Prevents click and touch events.
  • Ignores the content during keyboard navigation and text searches.

We can target our application root element (e.g., <div id="root">) and toggle the inert property.

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

  // Assuming your main app container has an id of 'root'
  const appRoot = document.getElementById('root');
  if (!appRoot) return;

  appRoot.setAttribute('inert', 'true');

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

Note: For older browsers that do not support inert natively, you can include the official W3C w3c/inert polyfill to ensure robust fallback behavior.


Putting It All Together

Here is our complete, production-ready Modal component combining all these accessibility patterns:

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

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

export const Modal: React.FC<ModalProps> = ({
  isOpen,
  onClose,
  title,
  children,
  appRootId = 'root',
}) => {
  const modalRef = useRef<HTMLDivElement>(null);
  const previousActiveElementRef = useRef<HTMLElement | null>(null);

  // 1. Focus restoration & Inert background handling
  useEffect(() => {
    if (isOpen) {
      previousActiveElementRef.current = document.activeElement as HTMLElement;

      const appRoot = document.getElementById(appRootId);
      if (appRoot) {
        appRoot.setAttribute('inert', 'true');
      }
    }

    return () => {
      if (isOpen) {
        const appRoot = document.getElementById(appRootId);
        if (appRoot) {
          appRoot.removeAttribute('inert');
        }

        if (previousActiveElementRef.current) {
          previousActiveElementRef.current.focus();
        }
      }
    };
  }, [isOpen, appRootId]);

  // 2. Focus trap & Escape key handler
  useEffect(() => {
    if (!isOpen) return;

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

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

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

    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 createPortal(
    <div className="modal-backdrop" onClick={onClose}>
      <div
        ref={modalRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        className="modal-container"
        onClick={(e) => e.stopPropagation()} // Prevent closing when clicking modal body
      >
        <header className="modal-header">
          <h2 id="modal-title">{title}</h2>
          <button onClick={onClose} aria-label="Close modal" className="close-btn">
            &times;
          </button>
        </header>
        <div className="modal-body">{children}</div>
      </div>
    </div>,
    document.body
  );
};

Conclusion

Building accessible UI components requires intentional effort beyond basic markup. By implementing focus trapping, leveraging the native inert attribute, handling Escape key events, and managing focus restoration, you ensure that users of all abilities can navigate your React applications seamlessly.

Take this component, add your organization’s design system styles, and build confidently knowing your modals meet the highest standards of web accessibility.

More posts