All posts
8 Oct 2026

Trapped and Accessible: Building a Production-Ready Modal Dialog in React

Learn how to build a fully accessible, custom modal dialog in React from scratch with robust focus trapping, inert background handling, and ARIA attributes.

Trapped and Accessible: Building a Production-Ready Modal Dialog in React

Modal dialogs are one of the most common UI patterns on the modern web, yet they are notoriously difficult to get right. If you have ever opened a modal, pressed the Tab key a few times, and watched your focus disappear into the background document, you know how frustrating inaccessible modals are for keyboard and screen reader users.

In this guide, we will build a production-ready, highly accessible modal dialog component from scratch in React using TypeScript. We won’t rely on heavy component libraries; instead, we will focus on the exact browser APIs and ARIA attributes required to make our modal universally usable.


The Anatomy of an Accessible Modal

To meet the Web Content Accessibility Guidelines (WCAG) and ensure a seamless experience for all users, a proper modal must handle four critical requirements:

  1. ARIA Attributes: Screen readers must instantly recognize the element as a modal dialog, read its title, and understand its purpose.
  2. Focus Trapping: Keyboard users must not be able to tab out of the modal into the background content while the modal is open.
  3. Background Neutralization (Inert): Assistive technologies and pointer events must ignore everything outside the modal.
  4. Keyboard & Focus Restoration: Pressing Escape must close the modal, and focus must return to the exact element that triggered the modal upon closing.

Step 1: Setting up the TypeScript Component Skeleton

Let’s start by defining our component props. We need a way to control the open state, handle closures, pass a title, and render children.

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

Using ReactDOM.createPortal ensures our modal escapes any parent container clipping or z-index stacking context issues by mounting it directly to document.body.


Step 2: Implementing Focus Trapping

When a modal opens, focus should immediately move inside it (usually to the first focusable element or the modal container itself). More importantly, pressing Tab or Shift + Tab must cycle through the focusable elements inside the modal without leaking outside.

Let’s write a robust focus-trap implementation using a useRef and a keyboard event listener.

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;

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

  // 2. 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"])',
  ];
  
  const focusableElements = modalElement.querySelectorAll<HTMLElement>(
    focusableSelectors.join(',')
  );

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

  // 3. Set initial focus
  if (firstElement) {
    firstElement.focus();
  } else {
    modalElement.focus();
  }

  // 4. Handle Tab key trapping
  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) {
          lastElement?.focus();
          e.preventDefault();
        }
      } else {
        // Tab
        if (document.activeElement === lastElement) {
          firstElement?.focus();
          e.preventDefault();
        }
      }
    }
  };

  document.addEventListener('keydown', handleKeyDown);

  return () => {
    document.removeEventListener('keydown', handleKeyDown);
    // 5. Restore focus on close
    previousActiveElement.current?.focus();
  };
}, [isOpen, onClose]);

Step 3: Making the Background Inert

Screen readers and mouse users should not be able to interact with the background application while a modal is open. Historically, developers used aria-hidden="true" on all sibling roots of the modal container. Today, modern browsers support the native inert attribute.

When an element is marked as inert, the browser ignores click events, removes it from the accessibility tree, and prevents keyboard focus.

Let’s apply this cleanly to our root application nodes:

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

  // Assuming your main app container has an id like 'root'
  const rootElement = document.getElementById('root');
  if (rootElement) {
    rootElement.setAttribute('inert', '');
  }

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

Step 4: Putting It All Together

Here is our complete, production-ready Modal component combining portals, ARIA attributes, focus management, escape handling, and inert backgrounds.

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 }) => {
  const modalRef = useRef<HTMLDivElement>(null);
  const previousActiveElement = useRef<HTMLElement | null>(null);

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

    previousActiveElement.current = document.activeElement as HTMLElement;
    const modalElement = modalRef.current;
    const rootElement = document.getElementById('root');

    if (rootElement) {
      rootElement.setAttribute('inert', '');
    }

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

    const focusableElements = modalElement?.querySelectorAll<HTMLElement>(
      focusableSelectors.join(',')
    );

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

    if (firstElement) {
      firstElement.focus();
    } else {
      modalElement?.focus();
    }

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

      if (e.key === 'Tab' && focusableElements && focusableElements.length > 0) {
        if (e.shiftKey && document.activeElement === firstElement) {
          lastElement?.focus();
          e.preventDefault();
        } else if (!e.shiftKey && document.activeElement === lastElement) {
          firstElement?.focus();
          e.preventDefault();
        }
      }
    };

    document.addEventListener('keydown', handleKeyDown);

    return () => {
      document.removeEventListener('keydown', handleKeyDown);
      if (rootElement) {
        rootElement.removeAttribute('inert');
      }
      previousActiveElement.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-label={title}
        tabIndex={-1}
        className="modal-content"
        onClick={(e) => e.stopPropagation()}
      >
        <header className="modal-header">
          <h2>{title}</h2>
          <button onClick={onClose} aria-label="Close modal">
            &times;
          </button>
        </header>
        <div className="modal-body">{children}</div>
      </div>
    </div>,
    document.body
  );
};

CSS Styling for Context

To ensure the modal looks centered and blocks interaction visually, apply standard backdrop styles:

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

.modal-content {
  background: white;
  padding: 2rem;
  border-radius: 8px;
  max-width: 500px;
  width: 100%;
  box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
  outline: none;
}

Conclusion

Building an accessible modal in React goes beyond styling a centered box. By combining aria-modal="true", proper focus trapping loops, native inert attributes for background shielding, and careful focus restoration, you create an inclusive component that works seamlessly for screen reader and keyboard-only users alike.

Take this component, drop it into your design system, and rest easy knowing your interactive overlays are fully compliant and robust.

More posts