All posts
7 Oct 2026

Trapped in the DOM: Building an Accessible Modal Dialog in React

Learn how to build a bulletproof, accessible modal dialog in React with a focus trap, portal rendering, escape key handling, and focus restoration.

Trapped in the DOM: Building an Accessible Modal Dialog in React

Modals are ubiquitous in modern web applications. They confirm deletions, display settings, and interrupt workflows. Yet, despite their commonality, they are notoriously difficult to implement correctly.

If you have ever opened a modal, pressed the Tab key a few times, and watched your focus escape into the background content behind an invisible barrier, you know the frustration. For screen reader and keyboard-only users, a poorly constructed modal is an impenetrable wall.

In this post, we are going to build a production-ready, highly accessible modal component in React using TypeScript. We will cover:

  • Rendering outside the parent DOM tree using React Portals.
  • Managing semantic markup with WAI-ARIA authoring practices.
  • Implementing an airtight focus trap.
  • Handling the Escape key and backdrop clicks.
  • Restoring focus to the trigger element upon closing.

Let’s dive in.


The Anatomy of an Accessible Modal

Before writing code, let’s review what makes a modal truly accessible. According to the WAI-ARIA 1.2 Dialog Pattern, a proper modal requires:

  1. Semantic Role: The container must have role="dialog" and aria-modal="true".
  2. Accessible Name: It must be labeled via aria-labelledby pointing to the modal header, or aria-label.
  3. Inert Background: Everything outside the modal should be hidden from assistive technologies.
  4. Initial Focus: Focus must move inside the modal immediately upon opening.
  5. Focus Trap: Keyboard focus must cycle strictly within the modal content.
  6. Focus Restoration: When the modal closes, focus must return to the element that triggered it.

Step 1: Setting up the Component API

Let’s define our TypeScript interface. Our modal needs to know if it’s open, a function to close it, an accessible title, and the children elements.

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

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

Step 2: Breaking Out with React Portals

Modals should live at the root of your DOM tree (usually as a direct child of <body>) to avoid z-index clipping issues and CSS containment problems. We use ReactDOM.createPortal for this.

export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
  if (!isOpen) return null;

  return createPortal(
    <div className="modal-backdrop">
      <div
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        className="modal-content"
      >
        <h2 id="modal-title">{title}</h2>
        {children}
        <button onClick={onClose}>Close</button>
      </div>
    </div>,
    document.body
  );
};

Step 3: Managing Focus (The Core Challenge)

To make this component bulletproof, we need three distinct focus-management mechanics:

  1. Remembering the trigger element so we can restore focus later.
  2. Trapping keyboard navigation inside the modal boundaries.
  3. Listening for global events like Escape.

Here is the complete implementation with all focus mechanics wired up:

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

  useEffect(() => {
    if (isOpen) {
      // 1. Save the current active element to restore later
      previousActiveElement.current = document.activeElement as HTMLElement;

      // 2. Focus the modal container or first focusable element
      if (modalRef.current) {
        const focusableElements = modalRef.current.querySelectorAll<HTMLElement>(
          'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
        );
        if (focusableElements.length > 0) {
          focusableElements[0].focus();
        }
      }

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

        if (event.key === 'Tab' && modalRef.current) {
          const focusableElements = modalRef.current.querySelectorAll<HTMLElement>(
            'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
          );
          
          if (focusableElements.length === 0) return;

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

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

      document.addEventListener('keydown', handleKeyDown);

      // Cleanup function
      return () => {
        document.removeEventListener('keydown', handleKeyDown);
        // 4. Restore focus to the trigger element
        if (previousActiveElement.current) {
          previousActiveElement.current.focus();
        }
      };
    }
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return createPortal(
    <div 
      className="modal-backdrop" 
      onClick={(e) => {
        if (e.target === e.currentTarget) onClose();
      }}
    >
      <div
        ref={modalRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        className="modal-content"
      >
        <h2 id="modal-title">{title}</h2>
        <div className="modal-body">
          {children}
        </div>
        <button className="close-btn" onClick={onClose}>
          Close
        </button>
      </div>
    </div>,
    document.body
  );
};

Step 4: Styling and Backdrop Interactions

To ensure the modal looks and feels correct, provide styling that centers the content and visually dims the background.

.modal-backdrop {
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  background-color: rgba(0, 0, 0, 0.5);
  display: flex;
  justify-content: center;
  align-items: 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;
}

Notice our click handler on .modal-backdrop: if (e.target === e.currentTarget) onClose();. This ensures clicking outside the modal box triggers a close, but clicking inside the modal content does not accidentally dismiss it.


Conclusion

Building accessible UI components requires intentional effort, but the payoff is immense. By combining React Portals, proper WAI-ARIA attributes, a robust focus trap, and focus restoration, you ensure that keyboard users and screen reader users have a seamless experience.

Next time you spin up a modal component, don’t let focus escape into the void—trap it responsibly!

More posts