All posts
4 Oct 2026

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

Learn how to build a production-ready, highly accessible modal component in React using TypeScript, React Portals, focus trapping, and WAI-ARIA standards.

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

Modals are one of the most common UI patterns in modern web development. They appear everywhere: confirmation dialogs, multi-step checkout flows, lightboxes, and alert banners. Yet, despite their ubiquity, they are frequently implemented incorrectly.

A poorly built modal is a usability and accessibility nightmare. Screen reader users might find themselves trapped behind the backdrop, keyboard users can easily tab out of the modal into the background content, and background scrolling can disorient the user entirely.

In this guide, we are going to build a production-ready, fully accessible modal component in React using TypeScript, React Portals, and modern WAI-ARIA authoring practices. We will cover:

  1. React Portals for correct DOM placement.
  2. WAI-ARIA Roles and Attributes for screen readers.
  3. Focus Trapping to keep keyboard navigation inside the modal.
  4. Escape Key Handling and outside-click dismissal.
  5. Body Scroll Locking to prevent background jank.

The Architecture of an Accessible Modal

Before writing code, let’s understand what makes a modal truly accessible. According to the WAI-ARIA Dialog Pattern, a modal dialog must satisfy several strict requirements:

  • DOM Context: It must sit logically at the root of the DOM to avoid CSS stacking context (z-index) and overflow clipping issues.
  • Semantics: It must use role="dialog", aria-modal="true", and reference an accessible name via aria-labelledby or aria-aria-label.
  • Initial Focus: When the modal opens, focus must move immediately to an element inside the modal (usually the first interactive element or the close button).
  • Focus Trap: Focus must be constrained within the modal boundaries while it is open.
  • Restoration of Focus: When the modal closes, focus must return to the exact element that triggered it.
  • Keyboard and Mouse Interactivity: Pressing Escape must close the modal, and clicking the backdrop (outside the modal container) should typically dismiss it.

Step 1: The React Portal Wrapper

By default, rendering a component in React places it deep inside the component tree hierarchy. If a parent container has overflow: hidden or a low z-index, your modal can easily break.

React Portals allow us to render children into a DOM node that exists outside the DOM hierarchy of the parent component. Usually, we mount modals directly to document.body or a dedicated #portal-root div.

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

interface PortalProps {
  children: React.ReactNode;
}

export const Portal: React.FC<PortalProps> = ({ children }) => {
  const [container] = useState(() => document.createElement('div'));

  useEffect(() => {
    document.body.appendChild(container);
    return () => {
      document.body.removeChild(container);
    };
  }, [container]);

  return ReactDOM.createPortal(children, container);
};

Step 2: Body Scroll Locking

When a modal is open, scrolling the background page feels broken. To prevent this, we must toggle overflow: hidden on the document.body when the modal mounts, and clean up after it unmounts.

import { useEffect } from 'react';

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

    const originalStyle = window.getComputedStyle(document.body).overflow;
    document.body.style.overflow = 'hidden';

    return () => {
      document.body.style.overflow = originalStyle;
    };
  }, [isOpen]);
};

Step 3: Focus Management and Trapping

The most complex part of a modal is managing keyboard focus. Without a focus trap, pressing the Tab key will cycle focus out of the modal and into the hidden application underneath.

We need to:

  1. Save the currently focused element when the modal opens.
  2. Query all focusable elements inside the modal.
  3. Listen for Tab key presses and loop the focus when it reaches the first or last element.
  4. Restore focus upon unmounting.
import { useEffect, useRef } from 'react';

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

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

    previousActiveElement.current = document.activeElement as HTMLElement;
    const modalElement = modalRef.current;

    if (modalElement) {
      const focusableElements = modalElement.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
      const firstElement = focusableElements[0];
      
      if (firstElement) {
        firstElement.focus();
      } else {
        modalElement.focus();
      }
    }

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

      if (event.key === 'Tab' && modalElement) {
        const focusableElements = modalElement.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
        if (focusableElements.length === 0) return;

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

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

    document.addEventListener('keydown', handleKeyDown);

    return () => {
      document.removeEventListener('keydown', handleKeyDown);
      if (previousActiveElement.current) {
        previousActiveElement.current.focus();
      }
    };
  }, [isOpen, onClose]);

  return modalRef;
};

Step 4: Putting It All Together in the Modal Component

Now we combine our hooks and portal logic into a clean, reusable React component. We will add accessibility attributes (role="dialog", aria-modal="true", and aria-labelledby) to ensure screen readers announce the modal correctly.

import React, { useId } from 'react';
import { Portal } from './Portal';
import { useBodyScrollLock } from './useBodyScrollLock';
import { useFocusTrap } from './useFocusTrap';

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

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

  if (!isOpen) return null;

  return (
    <Portal>
      <div className="modal-backdrop" onClick={onClose} aria-hidden="true">
        <div
          ref={modalRef}
          role="dialog"
          aria-modal="true"
          aria-labelledby={titleId}
          tabIndex={-1}
          className="modal-container"
          onClick={(e) => e.stopPropagation()}
        >
          <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>
    </Portal>
  );
};

Tip: Notice the onClick={(e) => e.stopPropagation()} on the modal container. This ensures that clicking inside the modal content does not accidentally trigger the backdrop click handler and close the modal.


Step 5: Styling the Modal

To make our modal visually clear, we need some basic CSS. The backdrop should cover the entire viewport with a semi-transparent background, and the modal container should be centered.

.modal-backdrop {
  position: fixed;
  inset: 0;
  background-color: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
  padding: 1rem;
}

.modal-container {
  background: #ffffff;
  border-radius: 8px;
  box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
  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: 1rem 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 {
  color: #111827;
  background-color: #f3f4f6;
}

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

Conclusion

Building an accessible modal from scratch requires attention to detail, but the payoff is immense. By implementing React Portals, body scroll locks, focus trapping, and proper ARIA roles, you ensure that every single user—regardless of whether they use a mouse, a screen reader, or a keyboard—has a seamless and frustration-free experience.

Next time you reach for a third-party UI library, consider building your own lightweight primitives. Understanding these underlying patterns makes you a stronger, more empathetic frontend engineer.

More posts