All posts
11 Oct 2026

Building an Accessible Modal Dialog in React: Focus Traps & Inert Content

A practical, code-heavy guide to building production-ready modal dialogs in React with robust focus trapping, inert background content, scroll locking, and accessible keyboard interactions.

Building an Accessible Modal Dialog in React: Focus Traps & Inert Content

When building user interfaces, few components are as deceptively complex as the modal dialog. At first glance, it’s just a box centered on the screen with a backdrop. But once you look through the lens of accessibility (a11y), a proper modal requires managing keyboard focus, hiding background content from screen readers, locking the body scroll, and handling escape keys gracefully.

In this walkthrough, we will build a robust, production-ready <Modal /> component in React using TypeScript. We will tackle the four pillars of modal accessibility:

  1. Focus Management: Trapping keyboard navigation within the modal.
  2. Screen Reader Isolation: Using the native inert attribute to render background content inaccessible.
  3. Scroll Locking: Preventing the underlying body from scrolling.
  4. Focus Restoration: Returning focus to the triggering element upon closure.

The Anatomy of an Accessible Modal

Before writing code, let’s review the WAI-ARIA Authoring Practices Guide (APG) requirements for a Dialog (Modal):

  • The dialog container must have role="dialog" and aria-modal="true".
  • It must have an accessible name, usually via aria-labelledby pointing to the title heading ID.
  • When open, focus must move to an element inside the dialog.
  • Tab and Shift+Tab must be trapped inside the dialog.
  • Pressing Escape must close the dialog.
  • Clicking the backdrop should optionally close the dialog.

Let’s implement these features step-by-step.


Step 1: The Component Skeleton & Portals

To prevent CSS clipping and z-index stacking context issues, our modal should render outside the normal DOM hierarchy 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 }) => {
  const modalRef = useRef<HTMLDivElement>(null);

  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()}
      >
        <h2 id="modal-title">{title}</h2>
        <div className="modal-content">{children}</div>
        <button onClick={onClose} aria-label="Close modal">
          Close
        </button>
      </div>
    </div>,
    document.body
  );
};

Step 2: Managing Focus Restoration and Trapping

When the modal opens, we need to save the element that currently has focus (the trigger) so we can restore it when the modal closes. Once open, we must trap keyboard focus inside the modal boundaries.

Let’s write a custom hook useFocusTrap to handle finding focusable elements and cycling through them when Tab or Shift+Tab is pressed.

import { useEffect, useRef } from 'react';

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

export function useFocusTrap(isOpen: boolean, onClose: () => void) {
  const containerRef = useRef<HTMLDivElement>(null);
  const previousActiveElement = useRef<HTMLElement | null>(null);

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

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

    const container = containerRef.current;
    if (!container) return;

    // 2. Focus the first focusable element inside the modal
    const focusableElements = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
    const firstElement = focusableElements[0];
    
    if (firstElement) {
      firstElement.focus();
    } else {
      container.focus(); // Fallback if no interactive elements
    }

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

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

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

        if (event.shiftKey) {
          // If shift + tab and focus is on first element, wrap to last
          if (document.activeElement === first) {
            last.focus();
            event.preventDefault();
          }
        } else {
          // If tab and focus is on last element, wrap to first
          if (document.activeElement === last) {
            first.focus();
            event.preventDefault();
          }
        }
      }
    };

    document.addEventListener('keydown', handleKeyDown);

    // 4. Cleanup: Restore focus when modal unmounts/closes
    return () => {
      document.removeEventListener('keydown', handleKeyDown);
      if (previousActiveElement.current) {
        previousActiveElement.current.focus();
      }
    };
  }, [isOpen, onClose]);

  return containerRef;
}

Step 3: Hiding Background Content with the inert Attribute

Screen readers can still navigate through background DOM nodes even if they are visually covered by a backdrop, unless those nodes are explicitly hidden. Historically, developers used aria-hidden="true" on the root app container. However, aria-hidden does not prevent keyboard focus from bleeding into background elements.

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

  • It and all its descendants are excluded from the accessibility tree.
  • The browser ignores user input events (clicks, focus) for that subtree.

Let’s write a utility hook to toggle inert on everything outside our modal (typically #root or main).

import { useEffect } from 'react';

export function useInertBackground(isOpen: boolean, rootSelector: string = '#root') {
  useEffect(() => {
    if (!isOpen) return;

    const rootElement = document.querySelector(rootSelector);
    if (!rootElement) return;

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

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

Step 4: Locking Body Scroll

When a modal is open, scrolling the background page creates a jarring user experience. We can prevent this by toggling overflow: hidden on the document.body.

import { useEffect } from 'react';

export function useScrollLock(isOpen: boolean) {
  useEffect(() => {
    if (!isOpen) return;

    const originalOverflow = document.body.style.overflow;
    const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth;

    // Lock scroll and add right padding to prevent layout shift from scrollbar disappearance
    document.body.style.overflow = 'hidden';
    if (scrollBarWidth > 0) {
      document.body.style.paddingRight = `${scrollBarWidth}px`;
    }

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

Step 5: Putting It All Together

Now, let’s combine our hooks into the final, complete <Modal /> component.

import React from 'react';
import { createPortal } from 'react-dom';
import { useFocusTrap } from './useFocusTrap';
import { useInertBackground } from './useInertBackground';
import { useScrollLock } from './useScrollLock';

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

export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
  const containerRef = useFocusTrap(isOpen, onClose);
  useInertBackground(isOpen, '#root');
  useScrollLock(isOpen);

  if (!isOpen) return null;

  return createPortal(
    <div className="modal-backdrop" onClick={onClose}>
      <div
        ref={containerRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        tabIndex={-1}
        className="modal-container"
        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
  );
};

Essential CSS Styling

To make our modal visually clear, add the following baseline styles:

.modal-backdrop {
  position: fixed;
  inset: 0;
  background-color: rgba(0, 0, 0, 0.6);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
  padding: 1rem;
}

.modal-container {
  background: #ffffff;
  border-radius: 8px;
  width: 100%;
  max-width: 500px;
  padding: 1.5rem;
  box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
  outline: none; /* Focus outline is handled via semantic state or custom styles if needed */
}

.modal-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 1rem;
}

.close-btn {
  background: none;
  border: none;
  font-size: 1.5rem;
  cursor: pointer;
}

Conclusion

Building an accessible modal goes far beyond visual styling. By combining React Portals, keyboard event listeners for focus trapping, the native inert attribute for screen reader isolation, and clean scroll-locking mechanics, you ensure that keyboard and screen reader users have a first-class experience.

Drop this pattern into your component library, and you’ll never have to worry about accessibility audits flagging trapped focus issues again!

More posts