All posts
24 Sep 2026

Trapped in the DOM: Building an Accessible Dialog and Modal Component in React

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

Trapped in the DOM: Building an Accessible Dialog and Modal Component in React

Modals are ubiquitous in modern web applications. Whether you are prompting a user to confirm a dangerous deletion, collecting feedback via a multi-step form, or displaying a rich image preview, the modal dialog is a fundamental UI pattern.

Yet, if you inspect how modals are implemented across the web, you will frequently find them broken. Screen reader users get lost behind hidden layers, keyboard operators find their focus bleeding out into the background document, and background page scrolling continues unhindered, causing layout jumps.

Building a truly accessible modal requires adherence to the WAI-ARIA Authoring Practices Guide (APG). In this guide, we will build a robust, production-ready, accessible modal and dialog component in React using TypeScript, React Portals, and native DOM APIs—without heavy third-party UI libraries.


The Anatomy of an Accessible Modal

Before writing code, let’s establish what makes a modal accessible. According to the WAI-ARIA specification, a dialog must satisfy the following criteria:

  1. Semantic Role: The container must have role="dialog" and aria-modal="true".
  2. Accessible Labeling: It must be labeled via aria-labelledby (pointing to a title element) or aria-label.
  3. Focus Management: When the modal opens, focus must move immediately to an element inside the dialog (ideally the first interactive element or the close button).
  4. Focus Trapping: Keyboard focus must be cycled indefinitely within the modal. Pressing Tab on the last focusable element must loop back to the first, and Shift + Tab on the first must loop to the last.
  5. Keyboard Dismissal: Pressing the Escape key must close the modal.
  6. Background Inertness: Content outside the modal must be hidden from screen readers and rendered inert (aria-hidden or inert).
  7. Scroll Locking: The background body element must not scroll while the modal is active.
  8. DOM Portaling: The modal must be rendered outside the standard React component hierarchy to prevent clipping issues caused by CSS stacking contexts (z-index and overflow: hidden).

Step 1: Setting up the Portal Component

To break free of parent container constraints, we use ReactDOM.createPortal. Let’s create a utility component that safely mounts our modal children into a dedicated DOM node.

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

interface PortalProps {
  children: React.ReactNode;
  containerId?: string;
}

export const Portal: React.FC<PortalProps> = ({ 
  children, 
  containerId = 'modal-root' 
}) => {
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
    let element = document.getElementById(containerId);
    
    if (!element) {
      element = document.createElement('div');
      element.setAttribute('id', containerId);
      document.body.appendChild(element);
    }

    return () => {
      // Clean up dynamic container if it becomes empty
      if (element && element.childElementCount === 0 && element.parentNode) {
        element.parentNode.removeChild(element);
      }
    };
  }, [containerId]);

  if (!mounted) return null;

  const portalNode = document.getElementById(containerId);
  return portalNode ? createPortal(children, portalNode) : null;
};

Step 2: Implementing Focus Trapping

Focus trapping is often the trickiest part of modal development. When a user presses Tab, we need to intercept the event, query all focusable elements within the modal, and manually adjust document.activeElement if the boundaries are breached.

Here is a helper hook to handle focus trapping:

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(isActive: boolean) {
  const containerRef = useRef<HTMLDivElement>(null);
  const previousActiveElement = useRef<HTMLElement | null>(null);

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

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

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

    // Focus the first focusable element inside the modal
    const focusableElements = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
    const firstElement = focusableElements[0];
    
    if (firstElement) {
      firstElement.focus();
    } else {
      container.focus(); // Fallback if no interactive elements exist
    }

    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key !== 'Tab') return;

      const focusable = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
      if (focusable.length === 0) return;

      const first = focusable[0];
      const last = focusable[focusable.length - 1];

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

    document.addEventListener('keydown', handleKeyDown);

    return () => {
      document.removeEventListener('keydown', handleKeyDown);
      // Restore focus to the element that triggered the modal
      if (previousActiveElement.current) {
        previousActiveElement.current.focus();
      }
    };
  }, [isActive]);

  return containerRef;
}

Step 3: Managing Background Inertness and Body Scroll

When a modal is active, assistive technologies should ignore everything happening in the background. Modern browsers support the inert HTML attribute, which disables interaction and hides elements from the accessibility tree simultaneously.

We also need to prevent background scrolling by manipulating document.body.style.overflow.

import { useEffect } from 'react';

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

    // 1. Lock background scroll
    const originalOverflow = document.body.style.overflow;
    document.body.style.overflow = 'hidden';

    // 2. Set background content to inert
    const rootElement = document.getElementById('root') || document.body.firstElementChild;
    const originalInert = rootElement?.getAttribute('inert');
    
    if (rootElement && rootElement.id !== 'modal-root') {
      rootElement.setAttribute('inert', 'true');
    }

    return () => {
      document.body.style.overflow = originalOverflow;
      if (rootElement && rootElement.id !== 'modal-root') {
        if (originalInert === null) {
          rootElement.removeAttribute('inert');
        } else {
          rootElement.setAttribute('inert', originalInert);
        }
      }
    };
  }, [isOpen]);
}

Step 4: Putting It All Together in the Dialog Component

Now we combine our hooks and portal logic into a clean, reusable Modal component.

import React, { useEffect, useId } from 'react';
import { Portal } from './Portal';
import { useFocusTrap } from './useFocusTrap';
import { useModalEffects } from './useModalEffects';
import './Modal.css';

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

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

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

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

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

  if (!isOpen) return null;

  return (
    <Portal>
      <div 
        className="modal-backdrop" 
        onClick={onClose}
        aria-hidden="true"
      >
        <div
          ref={containerRef}
          role="dialog"
          aria-modal="true"
          aria-labelledby={titleId}
          aria-describedby={description ? descId : undefined}
          tabIndex={-1}
          className="modal-container"
          onClick={(e) => e.stopPropagation()} // Prevent backdrop click-through
        >
          <div className="modal-header">
            <h2 id={titleId} className="modal-title">{title}</h2>
            <button 
              type="button" 
              className="modal-close-btn"
              onClick={onClose}
              aria-label="Close modal"
            >
              &times;
            </button>
          </div>
          
          {description && (
            <p id={descId} className="modal-description">
              {description}
            </p>
          )}

          <div className="modal-content">
            {children}
          </div>
        </div>
      </div>
    </Portal>
  );
};

Step 5: Styling the Modal

An accessible modal still needs to look polished. Here is a baseline CSS layout ensuring high-contrast backdrop overlays and predictable positioning.

.modal-backdrop {
  position: fixed;
  inset: 0;
  background-color: rgba(0, 0, 0, 0.6);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 9999;
  padding: 1rem;
  animation: fadeIn 0.2s ease-out;
}

.modal-container {
  background-color: #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;
  padding: 1.5rem;
  outline: none;
  display: flex;
  flex-direction: column;
  gap: 1rem;
  animation: scaleUp 0.2s ease-out;
}

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

.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:hover,
.modal-close-btn:focus-visible {
  color: #111827;
  background-color: #f3f4f6;
  outline: 2px solid #2563eb;
}

.modal-description {
  margin: 0;
  color: #4b5563;
  font-size: 0.875rem;
}

@keyframes fadeIn {
  from { opacity: 0; }
  to { opacity: 1; }
}

@keyframes scaleUp {
  from { transform: scale(0.95); opacity: 0; }
  to { transform: scale(1); opacity: 1; }
}

Testing Your Modal Implementation

Before deploying your new modal to production, verify it against these validation checkpoints:

  1. The Keyboard Test: Load your app, open the modal using a button click, and completely detach your mouse. Can you navigate every interactive element using only Tab and Shift + Tab? Does focus ever escape into the background application?
  2. The Escape Test: Does pressing Escape instantly dismiss the dialog and return focus precisely to the element that triggered it?
  3. The Screen Reader Audit: Turn on macOS VoiceOver (Cmd + F5) or NVDA on Windows. Ensure the screen reader announces the dialog title, description, and role immediately upon opening, while ignoring background elements.

By taking the time to implement correct focus trapping, DOM portals, and native attributes like inert, you ensure that your web applications remain welcoming and usable for every single user.

More posts