All posts
27 Sep 2026

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

A step-by-step engineering guide on implementing a WAI-ARIA compliant modal from scratch in React and TypeScript, focusing on focus trapping, scroll locking, and escape-key handling.

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

Modals are ubiquitous in modern web applications. Whether it’s a confirmation prompt, a settings panel, or an onboarding wizard, we build them every day. Yet, if you audit most custom modals in production using a screen reader or a keyboard alone, you’ll likely find a litany of accessibility violations.

Common pitfalls include:

  • Focus escaping the modal into the background content.
  • The screen reader completely ignoring the background page (or failing to ignore it).
  • The body background continuing to scroll when the modal is open.
  • The Escape key doing nothing.
  • Focus not returning to the triggering element when the modal closes.

In this guide, we are going to build a robust, production-ready, WAI-ARIA compliant Modal and Dialog component in React and TypeScript from scratch, without relying on heavy UI libraries.


The WAI-ARIA Anatomy of a Dialog

Before writing code, let’s review the specifications outlined in the WAI-ARIA Authoring Practices Guide (APG) for Modal Dialogs:

  1. Semantic Role: The dialog container must have role="dialog" and aria-modal="true".
  2. Labeling: The dialog must be labeled by its title via aria-labelledby or aria-label.
  3. Focus Management:
    • When the dialog opens, focus must move to an element inside the dialog.
    • While open, focus must be trapped inside the dialog (Tab and Shift+Tab cycle through focusable elements only).
    • When closed, focus must return to the element that triggered the modal.
  4. Keyboard Interactions: Pressing the Escape key must close the dialog.
  5. Background Inertness: Content outside the modal must be hidden from assistive technologies (aria-hidden="true") and rendered inert.

Step 1: Setting up the TypeScript Interface

Let’s define the props for our modal. We need to handle open states, close callbacks, labeling, and children.

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

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

Step 2: Implementing Scroll Locking and Background Inertness

When a modal opens, two things must happen to the DOM outside the modal:

  1. The background body must stop scrolling.
  2. Screen readers should not access background content.

While modern browsers support the HTML inert attribute (which natively handles both pointer events and accessibility tree exclusion), we should handle body scroll locking explicitly to prevent layout shifts.

import { useEffect } from 'react';

export function useBodyScrollLock(isOpen: boolean) {
  useEffect(() => {
    if (!isOpen) return;

    // Save original styles
    const originalOverflow = document.body.style.overflow;
    const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;

    // Lock scroll and prevent layout shift caused by scrollbar disappearance
    document.body.style.overflow = 'hidden';
    if (scrollbarWidth > 0) {
      document.body.style.paddingRight = `${scrollbarWidth}px`;
    }

    return () => {
      document.body.style.overflow = originalOverflow;
      document.body.style.paddingRight = '0px';
    };
  }, [isOpen]);
}

Step 3: The Focus Trap Hook

A focus trap ensures that when a user presses Tab or Shift+Tab, focus never leaves the modal container. If the user hits Tab on the last focusable element, focus wraps around to the first focusable element.

import { useEffect, useRef } from 'react';

const FOCUSABLE_SELECTOR = [
  '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: boolean, onClose: () => void) {
  const modalRef = useRef<HTMLDivElement>(null);
  const previousActiveElement = useRef<HTMLElement | null>(null);

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

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

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

    // 2. Find all focusable elements inside the modal
    const focusableElements = modalElement.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
    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 & escape key
    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key === 'Escape') {
        event.stopPropagation();
        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 when modal unmounts/closes
      previousActiveElement.current?.focus();
    };
  }, [isOpen, onClose]);

  return modalRef;
}

Step 4: Bringing it Together into the Modal Component

Now we can combine our hooks and proper ARIA attributes into a robust React component.

import React, { useId } from 'react';
import { createPortal } from 'react-dom';
import { ModalProps } from './types';
import { useBodyScrollLock } from './useBodyScrollLock';
import { useFocusTrap } from './useFocusTrap';

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

  if (!isOpen) return null;

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

Step 5: Essential CSS for Presentation

To ensure the modal looks and behaves correctly as an overlay, apply these structural styles:

.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-container {
  background: #ffffff;
  padding: 2rem;
  border-radius: 8px;
  width: 100%;
  max-width: 500px;
  box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
  outline: none; /* Removes default focus ring since we handle custom focus */
}

.modal-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 1rem;
}

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

.modal-close-btn {
  background: transparent;
  border: none;
  font-size: 1.5rem;
  cursor: pointer;
  padding: 0.25rem 0.5rem;
}

Testing Your Implementation

When evaluating your newly crafted modal, run through this quick checklist:

  1. Screen Reader Test: Open the modal using VoiceOver (macOS) or NVDA (Windows). Does it announce the title immediately? Does it read elements inside the modal before reading background content?
  2. Keyboard Trapping Test: Tab repeatedly inside the modal. Does focus ever leak into the background page? Does pressing Shift+Tab cycle backward correctly?
  3. Escape Key Test: Press Escape while focused anywhere in the modal. Does it close smoothly?
  4. Focus Restoration Test: When the modal closes, is the user’s keyboard focus returned precisely to the button that opened it?

By following this architecture, you ensure that every user—regardless of physical ability or device preference—can interact with your dialog seamlessly.

More posts