All posts
19 Oct 2026

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

Learn how to build a fully accessible modal dialog in React using modern primitives like the inert attribute, robust focus trapping, and seamless keyboard navigation.

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

Modal dialogs are among the most common UI patterns in modern web applications. They demand user attention, interrupt workflows, and serve everything from simple confirmation dialogs to complex multi-step forms.

Yet, despite their ubiquity, modals are frequently inaccessible. If built poorly, they trap screen reader users behind invisible walls, allow keyboard focus to bleed into background content, or cause layout shifts when locking the document body scroll.

In this guide, we will build a production-ready, highly accessible modal component in React and TypeScript. We will focus on the triad of modal accessibility:

  1. Focus Trapping: Keeping keyboard navigation strictly inside the active dialog.
  2. Inert Backgrounds: Hiding background content from assistive technologies and pointer events using the native inert attribute.
  3. Keyboard & Scroll Management: Handling the Escape key and preventing background layout shifts cleanly.

The Anatomy of an Accessible Modal

Before writing code, let’s establish what assistive technologies expect from a proper modal dialog according to the WAI-ARIA Authoring Practices Guide (APG):

  • Semantics: The root container must use role="dialog" and aria-modal="true".
  • Labeling: It must have an accessible name, usually provided via aria-labelledby pointing to the modal title element.
  • Initial Focus: When the modal opens, focus must immediately move to an interactive element inside the modal (ideally the close button or the first form input).
  • Return Focus: When the modal closes, focus must return to the exact element that triggered its opening.
  • Inertness: Everything outside the modal must be rendered inaccessible to both screen readers and keyboard users.

Step 1: Setting up the React Component and Portal

Modals should live at the root of your DOM tree (usually directly inside document.body) to avoid clipping issues caused by CSS overflow, z-index, or transform properties on parent containers. We will use ReactDOM.createPortal.

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-overlay">
      <div
        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">
            &times;
          </button>
        </div>
        <div className="modal-body">{children}</div>
      </div>
    </div>,
    document.body
  );
};

Step 2: Managing Focus and the Escape Key

When the modal opens, we need to save the element that currently has focus so we can restore it later. We also need to listen for the Escape key to close the modal.

Creating the Focus Trap

A focus trap prevents the user’s Tab key from leaving the modal. When the user tabs past the last focusable element, focus wraps back to the first element. Conversely, Shift + Tab on the first element wraps to the last.

Let’s write a custom hook or utility to handle query selectors for focusable elements:

const FOCUSABLE_ELEMENTS = [
  'a[href]',
  'area[href]',
  'input:not([disabled]):not([type="hidden"])',
  'select:not([disabled])',
  'textarea:not([disabled])',
  'button:not([disabled])',
  '[tabindex="0"]',
].join(', ');

Now, let’s integrate focus management and event listeners into our modal component:

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

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

    // 1. Save current focus
    previousActiveElement.current = document.activeElement as HTMLElement;

    // 2. Set initial focus inside modal
    const modalNode = modalRef.current;
    if (modalNode) {
      const focusableElements = modalNode.querySelectorAll<HTMLElement>(FOCUSABLE_ELEMENTS);
      if (focusableElements.length > 0) {
        focusableElements[0].focus();
      }
    }

    // 3. Handle Escape key and Tab trapping
    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key === 'Escape') {
        onClose();
        return;
      }

      if (event.key === 'Tab' && modalNode) {
        const focusableElements = modalNode.querySelectorAll<HTMLElement>(FOCUSABLE_ELEMENTS);
        if (focusableElements.length === 0) return;

        const firstElement = focusableElements[0];
        const lastElement = focusableElements[focusableElements.length - 1];

        if (event.shiftKey) {
          if (document.activeElement === firstElement) {
            lastElement.focus();
            event.preventDefault();
          }
        } else {
          if (document.activeElement === lastElement) {
            firstElement.focus();
            event.preventDefault();
          }
        }
      }
    };

    document.addEventListener('keydown', handleKeyDown);

    // Cleanup: restore focus
    return () => {
      document.removeEventListener('keydown', handleKeyDown);
      if (previousActiveElement.current) {
        previousActiveElement.current.focus();
      }
    };
  }, [isOpen, onClose]);

  if (!isOpen) return;

  return (
    // ... Portal markup goes here, attaching modalRef to the dialog
  );
};

Step 3: Making the Background Inert

Historically, developers used aria-hidden="true" on all sibling root containers of the modal to hide background content from screen readers, combined with complex click-handlers to block mouse events.

Today, modern browsers natively support the inert attribute. When an element is marked inert:

  • It is removed from the accessibility tree.
  • All descendant elements become un-clickable, un-focusable, and ignored by assistive technologies.

Implementing Inert in React

We can find our application root container (commonly #root or main) and toggle the inert property dynamically.

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

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

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

Note: For older browsers that do not support inert natively, you should include the official W3C w3c-inert polyfill to ensure backward compatibility.


Step 4: Preventing Background Scroll Without Layout Shift

When a modal opens, background scrolling must be locked. A naive approach sets document.body.style.overflow = 'hidden'. However, this causes a jarring horizontal layout shift (jank) on desktop operating systems because the vertical scrollbar disappears, causing the page width to expand instantly.

To prevent this, we calculate the width of the missing scrollbar and compensate by applying matching right padding to the body.

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

  const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
  const originalOverflow = document.body.style.overflow;
  const originalPaddingRight = document.body.style.paddingRight;

  document.body.style.overflow = 'hidden';
  if (scrollbarWidth > 0) {
    document.body.style.paddingRight = `${scrollbarWidth}px`;
  }

  return () => {
    document.body.style.overflow = originalOverflow;
    document.body.style.paddingRight = originalPaddingRight;
  };
}, [isOpen]);

Putting It All Together

Here is the complete, cohesive implementation of our accessible modal component:

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

interface ModalProps {
  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])',
  '[tabindex="0"]',
].join(', ');

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

  // 1. Body scroll lock & layout shift prevention
  useEffect(() => {
    if (!isOpen) return;

    const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
    const originalOverflow = document.body.style.overflow;
    const originalPaddingRight = document.body.style.paddingRight;

    document.body.style.overflow = 'hidden';
    if (scrollbarWidth > 0) {
      document.body.style.paddingRight = `${scrollbarWidth}px`;
    }

    return () => {
      document.body.style.overflow = originalOverflow;
      document.body.style.paddingRight = originalPaddingRight;
    };
  }, [isOpen]);

  // 2. Background inert management
  useEffect(() => {
    if (!isOpen) return;

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

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

  // 3. Focus trap and keyboard handling
  useEffect(() => {
    if (!isOpen) return;

    previousFocus.current = document.activeElement as HTMLElement;

    const modalNode = modalRef.current;
    if (modalNode) {
      const focusables = modalNode.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
      if (focusables.length > 0) focusables[0].focus();
    }

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

      if (e.key === 'Tab' && modalNode) {
        const focusables = modalNode.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
        if (focusables.length === 0) return;

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

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

    document.addEventListener('keydown', handleKeyDown);

    return () => {
      document.removeEventListener('keydown', handleKeyDown);
      if (previousFocus.current) {
        previousFocus.current.focus();
      }
    };
  }, [isOpen, onClose]);

  if (!isOpen) return null;

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

Conclusion

Building a robust, accessible modal in React requires careful coordination of semantics, DOM placement, keyboard listeners, and browser APIs. By leveraging the native inert attribute, implementing precise focus trapping routines, and gracefully handling scrollbar padding adjustments, you ensure that your modal works flawlessly for every user—regardless of whether they use a mouse, a keyboard, or a screen reader.

More posts