All posts
10 Oct 2026

Modal Dialogs Done Right: Accessible Overlays in React

Learn how to build a fully accessible, WCAG-compliant modal dialog in React and TypeScript featuring focus trapping, keyboard dismissal, and inert background content.

Modal Dialogs Done Right: Accessible Overlays in React

Modal dialogs are one of the most common UI patterns in modern web applications. Whether you are building a confirmation prompt, a complex form wizard, or an image lightbox, modals demand strict attention to detail.

Yet, if you test standard modal implementations against screen readers and keyboard-only navigation, many fall short. Without proper accessibility (a11y) considerations, keyboard users can find themselves trapped outside the modal while focus wanders into the background, or screen reader users may remain entirely unaware that a popup has appeared.

In this guide, we will build a production-ready, highly accessible modal dialog component in React using TypeScript. We will tackle the foundational pillars of modal accessibility:

  1. Semantic markup using proper ARIA attributes (aria-modal, role="dialog", etc.).
  2. Keyboard dismissal via the Escape key.
  3. Focus trapping to keep keyboard focus confined within the modal boundaries.
  4. Focus restoration to return the user’s cursor to the triggering element upon closure.
  5. Background isolation using the native inert attribute.

The Anatomy of an Accessible Modal

Before diving into code, let’s establish what makes a modal truly accessible according to the WAI-ARIA Authoring Practices Guide (APG):

  • Role: The container must have role="dialog" and aria-modal="true".
  • Labeling: It must have an accessible name, typically linked via aria-labelledby pointing to the modal title, or aria-label.
  • Initial Focus: When the modal opens, focus must move immediately to an element inside the modal (usually the first focusable element or the container itself).
  • Focus Trap: Pressing Tab or Shift + Tab must cycle through focusable elements only inside the modal.
  • Escape Key: Pressing Escape must close the modal.
  • Background Inertness: Content outside the modal should be hidden from assistive technologies and made un-interactive.

Let’s see how we implement this step by step in React and TypeScript.


Step 1: The TypeScript Interfaces and Base Component

Let’s start by defining our component props. We need props to control open/closed states, trigger dismissal, and provide accessibility labels.

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

export interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: ReactNode;
  initialFocusRef?: React.RefObject<HTMLElement>;
}

Next, let’s construct the skeleton of our component using React Portals. Portals allow us to render the modal outside the main DOM hierarchy, preventing clipping issues caused by CSS overflow: hidden or z-index stacking contexts.

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

  if (!isOpen) return null;

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

Step 2: Restoring Focus and Managing the Trigger

When a modal opens, we must record the element that triggered it (e.g., a button) so we can restore focus to it when the modal closes. If we fail to do this, screen reader and keyboard users are dropped back to the top of the document (<body>), forcing them to re-navigate the entire page.

We achieve this using useEffect hooks to capture and restore focus.

useEffect(() => {
  if (isOpen) {
    // 1. Save the currently focused element
    previousActiveElement.current = document.activeElement as HTMLElement;

    // 2. Set initial focus inside the modal
    if (initialFocusRef?.current) {
      initialFocusRef.current.focus();
    } else if (modalRef.current) {
      // Fallback: focus the modal container or first focusable element
      const focusableElements = getFocusableElements(modalRef.current);
      if (focusableElements.length > 0) {
        focusableElements[0].focus();
      } else {
        modalRef.current.focus();
      }
    }
  }

  // 3. Cleanup: restore focus when modal unmounts or closes
  return () => {
    if (previousActiveElement.current) {
      previousActiveElement.current.focus();
    }
  };
}, [isOpen, initialFocusRef]);

Step 3: Implementing the Focus Trap

If a user presses Tab while the last focusable element inside the modal is active, focus would normally escape into the browser UI or background document. To prevent this, we intercept keyboard events and manually redirect focus.

First, let’s write a utility function to query all focusable elements within a given container:

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(',');

function getFocusableElements(container: HTMLElement): HTMLElement[] {
  const elements = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
  return Array.from(elements).filter(
    (el) => el.offsetWidth > 0 || el.offsetHeight > 0 || el === document.activeElement
  );
}

Now, inside our component, we listen for keydown events and manage the tab loop:

useEffect(() => {
  const handleKeyDown = (event: KeyboardEvent) => {
    if (!modalRef.current) return;

    // Handle Escape Key Dismissal
    if (event.key === 'Escape') {
      event.stopPropagation();
      onClose();
      return;
    }

    // Handle Focus Trap on Tab Key
    if (event.key === 'Tab') {
      const focusableElements = getFocusableElements(modalRef.current);
      if (focusableElements.length === 0) return;

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

      if (event.shiftKey) {
        // Shift + Tab: if focused on first element, wrap to last
        if (document.activeElement === firstElement) {
          event.preventDefault();
          lastElement.focus();
        }
      } else {
        // Tab: if focused on last element, wrap to first
        if (document.activeElement === lastElement) {
          event.preventDefault();
          firstElement.focus();
        }
      }
    }
  };

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

Step 4: Isolating Background Content with the inert Attribute

Historically, hiding background content from screen readers and pointer devices while a modal was open required complex logic involving aria-hidden="true" applied to all sibling elements of the modal root, or managing manual click blockers.

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

  • It and all its descendants are hidden from accessibility trees.
  • It ignores click and touch events.
  • It is removed from the tab order.

We can easily apply this to our root application container (e.g., #root) when the modal opens:

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

  const rootElement = document.getElementById('root');
  if (rootElement) {
    rootElement.setAttribute('inert', '');
  }

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

Note: For older browser fallbacks, ensure critical content outside your portal wrapper utilizes aria-hidden="true", though inert is now widely supported across all modern evergreen browsers.


Putting It All Together

Here is the complete, cohesive TypeScript implementation of our accessible modal component:

import React, { useEffect, useRef, ReactNode } from 'react';
import ReactDOM from 'react-dom';
import './Modal.css';

export interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: ReactNode;
  initialFocusRef?: React.RefObject<HTMLElement>;
}

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(',');

function getFocusableElements(container: HTMLElement): HTMLElement[] {
  const elements = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
  return Array.from(elements).filter(
    (el) => el.offsetWidth > 0 || el.offsetHeight > 0 || el === document.activeElement
  );
}

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

  // Manage Focus Restoration
  useEffect(() => {
    if (isOpen) {
      previousActiveElement.current = document.activeElement as HTMLElement;

      if (initialFocusRef?.current) {
        initialFocusRef.current.focus();
      } else if (modalRef.current) {
        const focusableElements = getFocusableElements(modalRef.current);
        if (focusableElements.length > 0) {
          focusableElements[0].focus();
        } else {
          modalRef.current.focus();
        }
      }
    }

    return () => {
      if (previousActiveElement.current) {
        previousActiveElement.current.focus();
      }
    };
  }, [isOpen, initialFocusRef]);

  // Manage Inert Background Content
  useEffect(() => {
    if (!isOpen) return;

    const rootElement = document.getElementById('root');
    if (rootElement) {
      rootElement.setAttribute('inert', '');
    }

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

  // Manage Keydown Listeners (Escape & Tab Trap)
  useEffect(() => {
    if (!isOpen) return;

    const handleKeyDown = (event: KeyboardEvent) => {
      if (!modalRef.current) return;

      if (event.key === 'Escape') {
        event.stopPropagation();
        onClose();
        return;
      }

      if (event.key === 'Tab') {
        const focusableElements = getFocusableElements(modalRef.current);
        if (focusableElements.length === 0) return;

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

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

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

  if (!isOpen) return null;

  return ReactDOM.createPortal(
    <div className="modal-backdrop" onClick={onClose}>
      <div
        ref={modalRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        tabIndex={-1}
        className="modal-content"
        onClick={(e) => e.stopPropagation()}
      >
        <header className="modal-header">
          <h2 id="modal-title" className="modal-heading">
            {title}
          </h2>
          <button
            type="button"
            onClick={onClose}
            className="modal-close-btn"
            aria-label="Close modal"
          >
            &times;
          </button>
        </header>
        <div className="modal-body">{children}</div>
      </div>
    </div>,
    document.body,
  );
};

Conclusion

Building an accessible modal dialog requires going beyond basic CSS positioning and click handlers. By implementing proper ARIA attributes, robust focus trapping, seamless focus restoration, native inert background isolation, and keyboard listeners for the Escape key, you guarantee an inclusive experience for all users.

While writing custom hooks and wrapper components provides deep insight into web accessibility standards, if you are looking for battle-tested, highly accessible headless UI primitives for larger design systems, consider exploring libraries like Radix UI, Headless UI, or React Aria which handle these edge cases out of the box.

More posts