All posts
9 Oct 2026

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

Learn how to build a production-ready, highly accessible modal dialog in React from scratch, featuring focus trapping, keyboard navigation, and the native inert attribute.

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

Modals are a staple of modern web interfaces. Whether confirming a destructive action, displaying a user profile form, or showcasing an image gallery, developers reach for modals constantly. Yet, despite their ubiquity, modals are notoriously mishandled when it comes to accessibility (a11y).

If you have ever built a modal that lets screen reader users wander off into the background content, traps keyboard focus indefinitely, or fails to return focus when closed, you have built an accessibility anti-pattern.

In this guide, we will build a robust, accessible modal dialog in React and TypeScript from scratch. We will cover semantic HTML, focus trapping, handling the Escape key, restoring focus upon closure, and leveraging the modern inert attribute to handle background interactivity cleanly.


The Anatomy of an Accessible Modal

Before writing code, let’s list the non-negotiable requirements for an accessible modal dialog:

  1. Semantic HTML & ARIA: The container must use role="dialog", aria-modal="true", and be properly labeled using aria-labelledby and aria-describedby.
  2. Focus Management:
    • When the modal opens, focus must move immediately to an element inside the modal.
    • Focus must be trapped inside the modal (Tab and Shift+Tab cannot escape).
    • When the modal closes, focus must return to the exact element that triggered it.
  3. Keyboard Interactivity: Pressing the Escape key must close the modal.
  4. Background Isolation: Users must not be able to interact with or navigate through background content using a keyboard or screen reader.

Step 1: Component Shell and State

Let’s start by defining our TypeScript interface and setting up the basic React component. We’ll use a React portal to render the modal at the root of the document body, preventing layout and z-index issues.

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

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

export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
  if (!isOpen) return null;

  return createPortal(
    <div className="modal-overlay">
      <div 
        className="modal-content" 
        role="dialog" 
        aria-modal="true"
        aria-labelledby="modal-title"
      >
        <h2 id="modal-title">{title}</h2>
        <div className="modal-body">
          {children}
        </div>
        <button onClick={onClose} aria-label="Close modal">
          Close
        </button>
      </div>
    </div>,
    document.body
  );
};

Step 2: Restoring Focus and Handling Escape

When a modal opens, the user’s focus shifts inside. When it closes, that focus should snap right back to whatever element triggered it. We achieve this by tracking the element that held focus before the modal mounted using document.activeElement.

We also need an event listener for the Escape key.

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

  useEffect(() => {
    if (isOpen) {
      // 1. Save the currently focused element
      previousActiveElement.current = document.activeElement as HTMLElement;
    } else {
      // 2. Restore focus when modal closes
      previousActiveElement.current?.focus();
    }
  }, [isOpen]);

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

    if (isOpen) {
      document.addEventListener('keydown', handleKeyDown);
    }

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

  if (!isOpen) return null;

  // ... render logic
};

Step 3: Trapping Focus

If a user presses Tab while inside the modal, they shouldn’t cycle out of the modal and into the background page. A focus trap works by querying all focusable elements inside the modal and intercepting the Tab key event on the boundary elements (the first and last focusable items).

Let’s write a robust focus trapping hook/effect:

const modalRef = useRef<HTMLDivElement>(null);

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

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

  // Query all focusable elements inside the modal
  const focusableSelectors = [
    'a[href]',
    'area[href]',
    'input:not([disabled])',
    'select:not([disabled])',
    'textarea:not([disabled])',
    'button:not([disabled])',
    'iframe',
    'object',
    'embed',
    '[contenteditable]',
    '[tabindex]:not([tabindex="-1"])'
  ];

  const focusableElements = modalElement.querySelectorAll<HTMLElement>(
    focusableSelectors.join(',')
  );

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

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

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

    if (e.shiftKey) {
      // If shift + tab and focus is on first element, wrap to last
      if (document.activeElement === firstElement) {
        lastElement?.focus();
        e.preventDefault();
      }
    } else {
      // If tab and focus is on last element, wrap to first
      if (document.activeElement === lastElement) {
        firstElement?.focus();
        e.preventDefault();
      }
    }
  };

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

Step 4: Isolating Content with the inert Attribute

Historically, hiding background content from screen readers and pointer events required complex combinations of aria-hidden="true" applied to root app wrappers, alongside CSS tricks to disable pointer events.

Today, we have a native browser superpower: the inert attribute. When an element is marked as inert, the browser ignores click events, removes it from the accessibility tree, and disables text selection for that subtree entirely.

We can dynamically toggle inert on our root application container (e.g., #root) whenever the modal opens:

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

  // Assuming your main React app wrapper has id="root"
  const rootElement = document.getElementById('root');
  
  if (rootElement) {
    rootElement.setAttribute('inert', '');
  }

  return () => {
    if (rootElement) {
      rootElement.removeAttribute('inert');
    }
  };
}, [isOpen]);

Using inert drastically simplifies our markup. We no longer need to write custom logic to scan the DOM for what should be hidden; the browser handles background exclusion natively and performantly.


Putting It All Together

Here is our complete, fully accessible Modal component combining portals, focus restoration, escape listeners, focus trapping, and inert support:

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

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

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

  // Manage focus restoration
  useEffect(() => {
    if (isOpen) {
      previousActiveElement.current = document.activeElement as HTMLElement;
    } else {
      previousActiveElement.current?.focus();
    }
  }, [isOpen]);

  // Manage inert attribute on root element
  useEffect(() => {
    if (!isOpen) return;
    const rootElement = document.getElementById('root');
    rootElement?.setAttribute('inert', '');
    return () => {
      rootElement?.removeAttribute('inert');
    };
  }, [isOpen]);

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

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

    const focusableElements = modalElement.querySelectorAll<HTMLElement>(
      'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, [contenteditable], [tabindex]:not([tabindex="-1"])'
    );

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

    firstElement?.focus();

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

      if (e.key === 'Tab') {
        if (e.shiftKey && document.activeElement === firstElement) {
          lastElement?.focus();
          e.preventDefault();
        } else if (!e.shiftKey && document.activeElement === lastElement) {
          firstElement?.focus();
          e.preventDefault();
        }
      }
    };

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

  if (!isOpen) return null;

  return createPortal(
    <div className="modal-overlay" onClick={onClose}>
      <div 
        ref={modalRef}
        className="modal-content" 
        role="dialog" 
        aria-modal="true"
        aria-labelledby="modal-title"
        onClick={(e) => e.stopPropagation()} // Prevent closing when clicking inside modal
      >
        <h2 id="modal-title">{title}</h2>
        <div className="modal-body">
          {children}
        </div>
        <button onClick={onClose} className="modal-close-btn">
          Close
        </button>
      </div>
    </div>,
    document.body
  );
};

Conclusion

Building an accessible modal isn’t just about adding a nice CSS backdrop blur and a fade-in animation. It requires careful consideration of keyboard users, screen reader users, and state management.

By leveraging the native inert attribute, implementing precise focus trapping loops, and safely restoring user focus upon exit, you ensure that your React applications remain welcoming, compliant, and delightful for everyone.

More posts