All posts
5 Oct 2026

Trapped in the DOM: Building a Bulletproof Accessible Modal and Dialog in React

Master web accessibility by building a production-ready React modal using portals, custom hooks, focus trapping, and inert background management.

Trapped in the DOM: Building a Bulletproof Accessible Modal and Dialog in React

Modals and dialogs are ubiquitous in modern web applications. Whether they are used for confirming a destructive action, presenting a complex multi-step form, or displaying rich media, they represent a critical inflection point in user interaction.

Yet, despite their commonality, modals are among the most frequently misbuilt and inaccessible components on the web. A poorly implemented modal breaks screen reader navigation, traps keyboard-only users in infinite loops of frustration, or leaks focus into the background DOM.

In this installment of our accessibility-first UI series, we are going to build a production-ready, bulletproof modal dialog in React and TypeScript. We will leverage React Portals to render outside the main DOM hierarchy, construct a reusable custom hook for managing keyboard focus traps and focus restoration, and explore how to handle background inertness using modern browser APIs.


The Anatomy of an Accessible Dialog

Before writing any code, let’s establish the requirements for a fully accessible dialog based on the WAI-ARIA Authoring Practices Guide (APG):

  1. Semantic HTML: Use the native <dialog> element where appropriate, or assign the proper ARIA roles (role="dialog", aria-modal="true").
  2. Focus Management on Open: When the modal opens, focus must instantly move to an interactive element inside the modal (ideally the first focusable element or a close button).
  3. The Focus Trap: Keyboard users (pressing Tab or Shift + Tab) must cycle exclusively through the focusable elements inside the modal. Focus must never bleed into the background document.
  4. Escape Key Dismissal: Pressing the Escape key must close the modal.
  5. Focus Restoration on Close: When the modal closes, focus must return precisely to the element that triggered it (e.g., the button the user clicked to open the modal).
  6. Background Invalidation: Content outside the modal must be hidden from screen readers and rendered inert.

Step 1: Rendering via React Portals

Modals must break out of the standard DOM hierarchy to avoid clipping issues caused by parent elements with overflow: hidden, z-index stacking contexts, or CSS transforms. React Portals allow us to render children into a DOM node that exists outside the DOM hierarchy of the parent component.

Let’s create a portal wrapper component:

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

interface PortalProps {
  children: React.ReactNode;
  containerId?: string;
}

export const Portal: React.FC<PortalProps> = ({ 
  children, 
  containerId = 'modal-root' 
}) => {
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
    let portalRoot = document.getElementById(containerId);
    
    if (!portalRoot) {
      portalRoot = document.createElement('div');
      portalRoot.setAttribute('id', containerId);
      document.body.appendChild(portalRoot);
    }

    return () => {
      setMounted(false);
    };
  }, [containerId]);

  if (!mounted) return null;

  const portalRoot = document.getElementById(containerId);
  return portalRoot ? createPortal(children, portalRoot) : null;
};

Step 2: Crafting the useFocusTrap Custom Hook

The core complexity of an accessible modal lies in managing keyboard navigation. We need a custom hook that intercepts Tab key presses, tracks focusable elements, remembers where the focus came from, and restores it upon unmounting.

Let’s design useFocusTrap in TypeScript:

import { useEffect, useRef } from 'react';

interface UseFocusTrapOptions {
  isOpen: boolean;
  onClose: () => void;
}

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 function useFocusTrap({ isOpen, onClose }: UseFocusTrapOptions) {
  const modalRef = useRef<HTMLDivElement | null>(null);
  const previousActiveElement = useRef<HTMLElement | null>(null);

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

    // 1. Store the currently focused element to restore it later
    previousActiveElement.current = document.activeElement as HTMLElement;

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

    // 2. Query all focusable elements inside the modal
    const focusableElements = modalElement.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
    const firstElement = focusableElements[0];
    const lastElement = focusableElements[focusableElements.length - 1];

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

    // 4. Handle keyboard navigation & events
    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key === 'Escape') {
        event.preventDefault();
        onClose();
        return;
      }

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

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

    document.addEventListener('keydown', handleKeyDown);

    return () => {
      document.removeEventListener('keydown', handleKeyDown);

      // 5. Restore focus to the trigger element
      if (previousActiveElement.current && typeof previousActiveElement.current.focus === 'function') {
        previousActiveElement.current.focus();
      }
    };
  }, [isOpen, onClose]);

  return modalRef;
}

How the Hook Works:

  • Snapshotting Focus: When isOpen becomes true, document.activeElement is captured in a ref.
  • Focus Trapping: A global keydown listener monitors for Tab. If the user hits Tab while focused on the last focusable element, focus wraps around to the first element. Conversely, Shift + Tab wraps backward from the first to the last element.
  • Cleanup and Restoration: When the component unmounts or isOpen becomes false, focus is programmatically restored to previousActiveElement.current, ensuring a seamless keyboard experience.

Step 3: Managing Background Inertness

Merely trapping focus inside a modal is insufficient; screen readers can still navigate to background content unless that content is explicitly hidden or marked as inert.

The modern HTML inert attribute makes this trivial. When an element has the inert attribute, the browser ignores it for user interactions and accessibility tree construction.

Let’s add a utility effect to apply inert to all siblings of our portal root:

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

  const rootElement = document.getElementById('root');
  const modalRoot = document.getElementById('modal-root');
  
  // Apply inert to main application root
  if (rootElement) {
    rootElement.setAttribute('inert', '');
  }

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

Note: For older browsers that do not support the inert attribute natively, consider polyfilling it using the official w3c/wicg-inert package.


Step 4: Putting It All Together in the Modal Component

Now let’s assemble our complete, accessible modal component combining portals, focus trapping, ARIA roles, and keyboard handlers.

import React, { useEffect } from 'react';
import { Portal } from './Portal';
import { useFocusTrap } from './useFocusTrap';

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

export const Modal: React.FC<ModalProps> = ({
  isOpen,
  onClose,
  title,
  children,
})
 => {
  const modalRef = useFocusTrap({ isOpen, onClose });

  // Prevent body scroll when modal is open
  useEffect(() => {
    if (isOpen) {
      document.body.style.overflow = 'hidden';
    } else {
      document.body.style.overflow = 'unset';
    }
    return () => {
      document.body.style.overflow = 'unset';
    };
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <Portal>
      {/* Backdrop */} 
      <div 
        className="fixed inset-0 bg-black/50 transition-opacity z-40"
        aria-hidden="true"
        onClick={onClose}
      />

      {/* Modal Dialog Container */} 
      <div className="fixed inset-0 flex items-center justify-center z-50 p-4">
        <div
          ref={modalRef}
          role="dialog"
          aria-modal="true"
          aria-labelledby="modal-title"
          tabIndex={-1}
          className="bg-white rounded-lg shadow-xl max-w-md w-full p-6 outline-none focus:ring-2 focus:ring-blue-600"
        >
          <div className="flex items-center justify-between mb-4">
            <h2 id="modal-title" className="text-xl font-semibold text-gray-900">
              {title}
            </h2>
            <button
              onClick={onClose}
              aria-label="Close modal"
              className="text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded p-1"
            >
              ✕
            </button>
          </div>

          <div className="text-gray-600 mb-6">
            {children}
          </div>

          <div className="flex justify-end space-x-3">
            <button
              onClick={onClose}
              className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-500"
            >
              Cancel
            </button>
            <button
              onClick={() => {
                alert('Action confirmed!');
                onClose();
              }}
              className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
            >
              Confirm
            </button>
          </div>
        </div>
      </div>
    </Portal>
  );
};

Step 5: Consuming the Modal in Practice

Using our modal is straightforward. Notice how we don’t have to write any manual focus management code inside our parent component—the custom hook and portal handle everything cleanly behind the scenes.

import React, { useState } from 'react';
import { Modal } from './Modal';

export function App() {
  const [isModalOpen, setIsModalOpen] = useState(false);

  return (
    <main className="p-8">
      <h1 className="text-3xl font-bold mb-4">Dashboard</h1>
      <p className="text-gray-600 mb-6">
        Manage your account settings and preferences.
      </p>
      
      <button
        onClick={() => setIsModalOpen(true)}
        className="px-4 py-2 bg-blue-650 text-white rounded-lg shadow hover:bg-blue-750 focus:outline-none focus:ring-2 focus:ring-blue-500"
      >
        Open Confirmation Dialog
      </button>

      <Modal
        isOpen={isModalOpen}
        onClose={() => setIsModalOpen(false)}
        title="Are you sure?"
      >
        This action cannot be undone. This will permanently delete your account
        and remove your data from our servers.
      </Modal>
    </main>
  );
}

Testing Your Modal for Accessibility

Building accessibility-first components requires rigorous verification. Before shipping your modal to production, run through this quick QA checklist:

  1. Keyboard-Only Run: Unplug your mouse. Tab to the trigger button, press Enter, and verify that focus immediately lands inside the modal (on the close button or first interactive element).
  2. Tab Cycling: Press Tab repeatedly. Verify that focus cycles indefinitely between the modal’s internal interactive elements without ever escaping to the background document.
  3. Escape Key: Press Escape and verify that the modal closes smoothly and focus snaps back instantly to the trigger button.
  4. Screen Reader Audit: Use VoiceOver (macOS) or NVDA (Windows) to open the modal. Verify that the screen reader announces the dialog title (aria-labelledby), ignores background elements (inert), and restricts navigation to the modal content (aria-modal="true").

Conclusion

Accessibility is not an afterthought or an optional feature flag; it is a fundamental pillar of robust software engineering. By combining React Portals, custom hooks for focus trapping and restoration, and modern HTML features like the inert attribute, we can build modal dialogs that are performant, delightful, and fully accessible to every user on the web.

More posts