All posts
22 Sep 2026

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

Learn how to build a production-ready, highly accessible modal and dialog system in React and TypeScript featuring focus trapping, scroll locking, and screen reader announcements.

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

Modals are one of the most common UI patterns in modern web applications, yet they are notoriously difficult to implement correctly from an accessibility (a11y) standpoint. When a modal opens, it fundamentally alters the user’s context. Screen reader users need to know a dialog has appeared, keyboard-only users must be prevented from tabbing out into the background content, and background scrolling needs to be disabled without causing layout shifts.

In this technical guide, we will build a production-ready, fully accessible modal and dialog system from scratch using React, TypeScript, and React Portals. We will cover:

  1. Breaking out of the DOM hierarchy with Portals.
  2. Semantic HTML and WAI-ARIA attributes (aria-modal, aria-labelledby, aria-describedby).
  3. Implementing robust focus trapping.
  4. Handling background scroll locking.
  5. Managing escape key listeners and restoring focus.

1. The Foundation: Portals and Semantics

To ensure our modal renders above all other content and escapes CSS stacking contexts (like overflow: hidden or z-index traps on parent containers), we must render it outside the main React root using ReactDOM.createPortal.

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 [mountNode, setMountNode] = useState<HTMLElement | null>(null);

  useEffect(() => {
    const div = document.createElement('div');
    div.setAttribute('data-portal-root', '');
    document.body.appendChild(div);
    setMountNode(div);

    return () => {
      document.body.removeChild(div);
    };
  }, []);

  if (!mountNode) return null;

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

WAI-ARIA Requirements

According to the WAI-ARIA Authoring Practices Guide (APG) for a Dialog (Modal), a proper modal must include:

  • role="dialog" to identify the element as a dialog.
  • aria-modal="true" to inform assistive technologies that the rest of the page is inert.
  • aria-labelledby pointing to the modal’s heading ID.
  • aria-describedby (optional) pointing to a descriptive text element.

2. Managing Scroll Locking

When a modal is open, scrolling the background page creates a jarring user experience. We can prevent this by toggling a CSS class on the <body> element or directly mutating its style property.

import { useEffect } from 'react';

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

    const originalOverflow = document.body.style.overflow;
    const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;

    document.body.style.overflow = 'hidden';
    document.body.style.paddingRight = `${scrollbarWidth}px`; // Prevents layout shift

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

3. The Core Challenge: Focus Trapping

When a user presses the Tab key inside a modal, focus must cycle exclusively through the interactive elements inside the modal. If the user tabs past the last focusable element, focus must wrap back to the first element. If they press Shift + Tab on the first element, it must jump to the last.

Let’s write a custom hook that manages this focus cycle:

import { useEffect, useRef } from 'react';

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

export function useFocusTrap(isOpen: boolean) {
  const containerRef = useRef<HTMLDivElement>(null);
  const previousActiveElement = useRef<HTMLElement | null>(null);

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

    // Save the element that had focus before the modal opened
    previousActiveElement.current = document.activeElement as HTMLElement;

    const container = containerRef.current;
    if (!container) return;

    // Find all focusable elements inside the modal
    const focusableElements = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
    const firstElement = focusableElements[0];
    const lastElement = focusableElements[focusableElements.length - 1];

    // Automatically focus the first element on mount
    firstElement?.focus();

    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key !== 'Tab') 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);
      // Restore focus to the trigger element when the modal closes
      previousActiveElement.current?.focus();
    };
  }, [isOpen]);

  return containerRef;
}

4. Handling Escape Keys and Backdrop Clicks

Users expect to close modals by pressing the Escape key or by clicking outside the modal content area (the backdrop).

import { useEffect } from 'react';

export function useModalDismiss(isOpen: boolean, onClose: () => void) {
  useEffect(() => {
    if (!isOpen) return;

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

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

5. Putting It All Together: The Modal Component

Now we combine our custom hooks (useScrollLock, useFocusTrap, useModalDismiss) and the Portal component into a unified, reusable TypeScript component.

import React from 'react';
import { Portal } from './Portal';
import { useScrollLock } from './useScrollLock';
import { useFocusTrap } from './useFocusTrap';
import { useModalDismiss } from './useModalDismiss';

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

export const Modal: React.FC<ModalProps> = ({
  isOpen,
  onClose,
  title,
  description,
  children,
}) => {
  const containerRef = useFocusTrap(isOpen);
  useScrollLock(isOpen);
  useModalDismiss(isOpen, onClose);

  if (!isOpen) return null;

  const titleId = 'modal-title';
  const descId = description ? 'modal-description' : undefined;

  return (
    <Portal>
      <div
        style={backdropStyles}
        onClick={(e) => {
          if (e.target === e.currentTarget) onClose();
        }}
      >
        <div
          ref={containerRef}
          role="dialog"
          aria-modal="true"
          aria-labelledby={titleId}
          aria-describedby={descId}
          style={modalStyles}
        >
          <div style={headerStyles}>
            <h2 id={titleId} style={{ margin: 0 }}>{title}</h2>
            <button onClick={onClose} aria-label="Close modal" style={closeButtonStyles}>
              &times;
            </button>
          </div>

          {description && (
            <p id={descId} style={{ color: '#666', marginTop: '4px' }}>
              {description}
            </p>
          )}

          <div style={{ marginTop: '16px' }}>{children}</div>
        </div>
      </div>
    </Portal>
  );
};

/* --- Basic Inline Styles for Demonstration --- */

const backdropStyles: React.CSSProperties = {
  position: 'fixed',
  top: 0,
  left: 0,
  width: '100vw',
  height: '100vh',
  backgroundColor: 'rgba(0, 0, 0, 0.5)',
  display: 'flex',
  alignItems: 'center',
  justifyContent: 'center',
  zIndex: 1000,
};

const modalStyles: React.CSSProperties = {
  backgroundColor: '#ffffff',
  borderRadius: '8px',
  padding: '24px',
  width: '100%',
  maxWidth: '500px',
  boxShadow: '0 4px 20px rgba(0, 0, 0, 0.15)',
  outline: 'none',
};

const headerStyles: React.CSSProperties = {
  display: 'flex',
  justifyContent: 'space-between',
  alignItems: 'center',
};

const closeButtonStyles: React.CSSProperties = {
  background: 'none',
  border: 'none',
  fontSize: '1.5rem',
  cursor: 'pointer',
};

Conclusion

Building an accessible modal in React requires careful attention to browser behaviors that are normally handled automatically. By encapsulating React Portals, Focus Trapping, Scroll Locking, and Keyboard Event Listeners into custom hooks, you create a declarative API that makes building accessible UI patterns straightforward for your entire engineering team.

Key Takeaways Checklist:

  • Render via a Portal to escape CSS stacking contexts.
  • Use role="dialog" and aria-modal="true".
  • Trap focus entirely inside the dialog boundaries.
  • Return focus to the trigger element on close.
  • Handle Escape key presses and backdrop clicks gracefully.

More posts