All posts
21 Sep 2026

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

Learn how to build a production-ready, accessible React modal from scratch using TypeScript, handling focus trapping, screen reader announcements, and scroll locking without third-party libraries.

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

Modals are ubiquitous in modern web development. Whether you are building a critical confirmation dialog, a complex multi-step form, or a media lightbox, modals are essential for focused user interaction.

However, if you test a typical modal built with a naive <div> and absolute positioning against screen readers or keyboard navigation, you will quickly discover how fragile it is. Users can tab out of the modal into the background content, background pages continue to scroll, screen readers read out obscured page content, and pressing the Escape key does nothing.

In this guide, we will build a production-ready, highly accessible modal component in React and TypeScript from scratch. We will cover:

  • WAI-ARIA Dialog Pattern compliance
  • Imperative focus management and focus trapping
  • Preventing background scrolling
  • Utilizing the modern inert attribute
  • Zero third-party bloat

The Anatomy of an Accessible Modal

Before writing code, let’s establish what makes a modal truly accessible. According to the WAI-ARIA Authoring Practices Guide (APG), a dialog (modal) must adhere to these foundational rules:

  1. Semantic Structure: It must use the correct roles (role="dialog"), aria attributes (aria-modal="true"), and label associations (aria-labelledby and aria-describedby).
  2. Focus Management: When the modal opens, focus must move immediately to an element inside the modal. When the modal closes, focus must return to the trigger element that opened it.
  3. Focus Trapping: Keyboard users (using the Tab key) must not be able to navigate outside the modal container while it is open.
  4. Keyboard Dismissal: Pressing the Escape key must close the modal.
  5. Background Inertness: Content outside the modal must be hidden from screen readers and removed from the keyboard navigation flow.

Step 1: Component Interface and Setup

Let’s start by defining our TypeScript interface. Our modal needs to accept an isOpen boolean, an onClose callback, a title, and children.

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

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

Step 2: Managing Focus and the Escape Key

Focus management is often where custom modals fail. When a modal opens, we need to save a reference to the element that currently has focus (the trigger button). When the modal closes, we return focus to that element.

Inside the modal, we must also trap the Tab key so focus cycles infinitely between the first and last focusable elements inside the modal content.

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

  // Handle Escape key and focus return
  useEffect(() => {
    if (!isOpen) return;

    // 1. Save current focus
    previousActiveElement.current = document.activeElement as HTMLElement;

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

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

      // Focus Trap logic
      if (event.key === 'Tab' && modalElement) {
        const focusableElements = Array.from(
          modalElement.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];

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

    document.addEventListener('keydown', handleKeyDown);

    // Cleanup: restore focus when modal unmounts or closes
    return () => {
      document.removeEventListener('keydown', handleKeyDown);
      if (previousActiveElement.current) {
        previousActiveElement.current.focus();
      }
    };
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return (
    // JSX structure coming up next...
  );
};

Pro-Tip: Querying focusable elements dynamically on every Tab press ensures that even if elements inside your modal are conditionally rendered or disabled, your focus trap remains resilient.


Step 3: Scroll Locking and Background Inertness

When a modal is open, users should not be able to scroll the underlying background page. Furthermore, screen readers should completely ignore background content.

Historically, developers toggled overflow: hidden on the body element and added aria-hidden="true" to root application wrappers. Today, we can leverage the native HTML inert attribute alongside standard overflow styling.

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

  // Prevent background scrolling
  const originalOverflow = document.body.style.overflow;
  document.body.style.overflow = 'hidden';

  // Make background inert for assistive tech
  const rootElement = document.getElementById('root');
  if (rootElement) {
    rootElement.setAttribute('inert', '');
  }

  return () => {
    document.body.style.overflow = originalOverflow;
    if (rootElement) {
      rootElement.removeAttribute('inert');
    }
  };
}, [isOpen]);

The inert attribute is a modern web standard supported across all major browsers. When an element is marked as inert, the browser ignores click events, removes it from the accessibility tree, and excludes it from tab navigation.


Step 4: Putting It Together with WAI-ARIA Markup

Now let’s assemble the complete JSX markup, ensuring we hook up role="dialog", aria-modal="true", and labeling IDs correctly.

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

  // ... (Include useEffect hooks for focus, escape key, and inert management from previous steps)

  if (!isOpen) return null;

  return (
    <div className="modal-backdrop" onClick={onClose}>
      <div
        ref={modalRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby={titleId}
        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-btn"
          >
            &times;
          </button>
        </header>
        <div className="modal-body">
          {children}
        </div>
      </div>
    </div>
  );
};

Step 5: Styling the Modal

To ensure our modal feels solid and visually centers properly, here is a clean, modern CSS setup:

.modal-backdrop {
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  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;
  box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
  width: 100%;
  max-width: 500px;
  max-height: 90vh;
  display: flex;
  flex-direction: column;
  outline: none;
}

.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-btn {
  background: transparent;
  border: none;
  font-size: 1.5rem;
  cursor: pointer;
  color: #6b7280;
  padding: 0.25rem 0.5rem;
  border-radius: 4px;
}

.modal-close-btn:focus-visible {
  outline: 2px solid #2563eb;
  outline-offset: 2px;
}

.modal-body {
  padding: 1.5rem;
  overflow-y: auto;
}

Testing Your Modal

Before shipping your new modal component to production, verify it against this checklist:

  1. Keyboard-Only Test: Unplug your mouse. Press your trigger button, verify focus moves inside the modal, press Tab repeatedly to ensure focus stays trapped, and press Escape to close it.
  2. Screen Reader Test: Turn on VoiceOver (macOS) or NVDA (Windows). Open the modal. Confirm that background content is ignored and screen readers announce the dialog title immediately.
  3. Scroll Test: Open the modal on a long page. Ensure the background body does not scroll while the modal is active.

By building modals with accessibility baked in from the ground up, you deliver a resilient, inclusive experience for all users while keeping your React bundle lean and dependency-free.

More posts