All posts
25 Sep 2026

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

A comprehensive, code-heavy guide to building a fully accessible modal dialog in React with focus trapping, ARIA attributes, and robust keyboard navigation.

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

Building a modal dialog is one of the most common frontend tasks, yet it is surprisingly easy to get wrong from an accessibility (a11y) standpoint. A truly accessible modal isn’t just a box floating over a dimmed background; it requires meticulous management of keyboard focus, screen reader announcements, semantic ARIA attributes, and pointer events.

In this guide, we will build a production-ready, highly accessible modal dialog component in React using TypeScript. By the end of this post, you’ll understand how to implement focus trapping, handle the Escape key, restore focus upon closing, and properly wire up ARIA attributes without relying on heavy external UI libraries.


The Anatomy of an Accessible Modal

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

  1. Semantic HTML & ARIA: The dialog container must have role="dialog", aria-modal="true", and be labeled using aria-labelledby (and optionally aria-describedby).
  2. Focus Management (Trapping): When the modal opens, focus must move inside it. Keyboard users (Tab / Shift+Tab) must not be able to navigate to elements outside the modal.
  3. Restoring Focus: When the modal closes, focus must return to the exact element that triggered it.
  4. Keyboard Interactions: Pressing the Escape key must close the modal.
  5. Background Inertness: Content outside the modal should be hidden from assistive technologies and rendered inert.

Step 1: Setting up the TypeScript Interfaces

Let’s start by defining our component props. A robust modal needs to know if it’s open, a function to close it, an accessible title, and optional description IDs.

tsx
import React, { ReactNode } from 'react';

export interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: ReactNode;
  /** Optional ID for a description element */
  describedBy?: string;
}

Step 2: Implementing Focus Restoration

When a user clicks a button to open a modal, their focus is currently on that trigger button. When the modal closes, failing to restore focus forces screen reader and keyboard users to navigate all the way back from the top of the page.

We capture the active element immediately when the modal opens and restore it during cleanup.

import React, { useEffect, useRef } from 'react';

export const useRestoreFocus = (isOpen: boolean) => {
  const previouslyFocusedElementRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (isOpen) {
      // Store the currently focused element before the modal opens
      previouslyFocusedElementRef.current = document.activeElement as HTMLElement;
    } else {
      // Restore focus when the modal closes
      if (previouslyFocusedElementRef.current && typeof previouslyFocusedElementRef.current.focus === 'function') {
        previouslyFocusedElementRef.current.focus();
      }
    }
  }, [isOpen]);
};

Step 3: Engineering the Focus Trap

A focus trap prevents keyboard focus from escaping the modal boundaries. To achieve this, we query all focusable elements inside our modal container and listen for the Tab key.

const FOCUSABLE_SELECTORS = [
  'a[href]',
  'area[href]',
  'input:not([disabled])',
  'select:not([disabled])',
  'textarea:not([disabled])',
  'button:not([disabled])',
  'iframe',
  'object',
  'embed',
  '[contenteditable]',
  '[tabindex]:not([tabindex="-1"])',
].join(',');

export const useFocusTrap = (isOpen: boolean, modalRef: React.RefObject<HTMLDivElement>) => {
  useEffect(() => {
    if (!isOpen || !modalRef.current) return;

    const modalElement = modalRef.current;
    
    // Automatically focus the first focusable element inside the modal upon opening
    const focusableElements = modalElement.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
    const firstElement = focusableElements[0];
    const lastElement = focusableElements[focusableElements.length - 1];

    if (firstElement) {
      firstElement.focus();
    } else {
      // Fallback: focus the modal container itself if no interactive elements exist
      modalElement.focus();
    }

    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key !== 'Tab') return;

      const currentFocusable = modalElement.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
      const first = currentFocusable[0];
      const last = currentFocusable[currentFocusable.length - 1];

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

    document.addEventListener('keydown', handleKeyDown);
    return () => {
      document.removeEventListener('keydown', handleKeyDown);
    };
  }, [isOpen, modalRef]);
};

Step 4: Putting It All Together in the Modal Component

Now, let’s assemble our hooks, ARIA attributes, and event listeners into a complete React component.

import React, { useRef, useId, useEffect } from 'react';
import { ModalProps } from './types';
import { useRestoreFocus } from './useRestoreFocus';
import { useFocusTrap } from './useFocusTrap';

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

  // Handle focus lifecycle
  useRestoreFocus(isOpen);
  useFocusTrap(isOpen, modalRef);

  // Handle ESC key to close
  useEffect(() => {
    if (!isOpen) return;

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

    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [isOpen, onClose]);

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

  if (!isOpen) return null;

  return (
    <div className="modal-backdrop" onClick={onClose}>
      <div
        ref={modalRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby={titleId}
        aria-describedby={describedBy}
        tabIndex={-1}
        className="modal-container"
        onClick={(e) => e.stopPropagation()} // Prevent backdrop click from closing when clicking modal body
      >
        <header className="modal-header">
          <h2 id={titleId} className="modal-title">
            {title}
          </h2>
          <button
            type="button"
            onClick={onClose}
            aria-label="Close modal"
            className="modal-close-button"
          >
            &times;
          </button>
        </header>
        <div className="modal-body">
          {children}
        </div>
      </div>
    </div>
  );
};

Step 5: Essential CSS Styling

A good accessibility implementation should be accompanied by clear visual states. Here is a baseline stylesheet to ensure proper positioning, contrast, and layout.

.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;
  box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
  display: flex;
  flex-direction: column;
  outline: none; /* Focus outline managed via custom styles or container purpose */
}

.modal-container:focus-visible {
  box-shadow: 0 0 0 3px #2563eb, 0 20px 25px -5px rgba(0, 0, 0, 0.1);
}

.modal-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 1.25rem 1.5rem;
  border-bottom: 1px solid #e5e7eb;
}

.modal-title {
  margin: 0;
  font-size: 1.25rem;
  font-weight: 600;
  color: #111827;
}

.modal-close-button {
  background: transparent;
  border: none;
  font-size: 1.5rem;
  cursor: pointer;
  color: #6b7280;
  padding: 0.25rem 0.5rem;
  border-radius: 4px;
}

.modal-close-button:hover,
.modal-close-button:focus-visible {
  color: #111827;
  background-color: #f3f4f6;
  outline: 2px solid #2563eb;
}

.modal-body {
  padding: 1.5rem;
  color: #4b5563;
}

Testing Your Implementation

Before deploying your modal component to production, perform these manual accessibility audits:

  1. The Keyboard-Only Test: Unplug your mouse or trackpad. Click your trigger button via keyboard (Enter or Space), press Tab repeatedly to ensure focus stays exclusively within the modal, and hit Escape to close it.
  2. Screen Reader Verification: Test the component with a screen reader like VoiceOver (macOS) or NVDA (Windows). Confirm that when the modal opens, the screen reader announces the title immediately, identifies it as a dialog, and ignores background elements.
  3. DOM Inspection: Check that aria-modal="true" is present and that aria-labelledby points to a valid heading ID.

Conclusion

Building accessible components requires deliberate planning beyond visual UI design. By managing focus traps, keeping track of previous active elements, and leveraging proper WAI-ARIA roles, you ensure that every user—regardless of ability or input device—can successfully navigate your React application.

More posts