All posts
20 Oct 2026

Building an Accessible Modal Dialog in React: Focus Trapping and Portal Architecture

A deep dive into building a production-ready React modal component from scratch, focusing on DOM portals, keyboard focus trapping, accessibility attributes, and focus restoration.

Building an Accessible Modal Dialog in React: Focus Trapping and Portal Architecture

Modals are ubiquitous in modern web applications. Whether they are used for confirming a destructive action, presenting a multi-step checkout form, or displaying detailed item previews, modals demand user attention by blocking interaction with the rest of the application.

However, building a truly accessible modal is notoriously difficult. If not implemented correctly, modals can break screen reader workflows, trap keyboard-only users in infinite tab loops, or leak focus outside the dialog container.

In this deep dive, we will construct a production-ready, fully accessible React modal component from scratch. We will cover:

  1. DOM Portals to escape CSS stacking context constraints.
  2. WAI-ARIA Authoring Practices (aria-modal, aria-labelledby, role="dialog").
  3. Keyboard Focus Trapping to keep navigation within the modal boundary.
  4. Background Inertness to screen out non-modal content for assistive technologies.
  5. Focus Restoration to return the user’s cursor to the trigger element upon closure.

1. The Architecture: Why Portals Matter

Before diving into accessibility, let’s address layout and DOM structure. A modal should conceptually live at the root of the DOM tree, regardless of where its trigger button resides in the component hierarchy. If you render a modal inside a deeply nested flex container or a parent with overflow: hidden, your absolute or fixed positioning will often break.

React Portals allow us to render children into a DOM node that exists outside the DOM hierarchy of the parent component.

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

interface PortalProps {
  children: React.ReactNode;
}

export const Portal: React.FC<PortalProps> = ({ children }) => {
  const [mountNode, setMountNode] = useState<HTMLElement | null>(null);

  useEffect(() => {
    let portalRoot = document.getElementById('modal-root');
    
    if (!portalRoot) {
      portalRoot = document.createElement('div');
      portalRoot.setAttribute('id', 'modal-root');
      document.body.appendChild(portalRoot);
    }
    
    setMountNode(portalRoot);
  }, []);

  if (!mountNode) return null;

  return createPortal(children, mountNode);
};

2. Semantic HTML and WAI-ARIA Attributes

Screen readers rely heavily on specific ARIA attributes to understand that a modal has interrupted the normal page flow. According to the WAI-ARIA Modal Dialog Pattern, our container 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 or obscured.
  • aria-labelledby pointing to the ID of the modal’s header element.
  • aria-describedby (optional) pointing to a summary description.

Let’s draft the basic markup structure inside our modal component:

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;

  const titleId = 'modal-title';

  return (
    <Portal>
      <div className="modal-backdrop" onClick={onClose}>
        <div 
          className="modal-container"
          role="dialog"
          aria-modal="true"
          aria-labelledby={titleId}
          onClick={(e) => e.stopPropagation()}
        >
          <header className="modal-header">
            <h2 id={titleId}>{title}</h2>
            <button aria-label="Close modal" onClick={onClose}>
              &times;
            </button>
          </header>
          <div className="modal-body">
            {children}
          </div>
        </div>
      </div>
    </Portal>
  );
};

3. Keyboard Interactions: Escape Key Dismissal

Users expect to press the Escape key to close an open modal. We can implement this with a simple useEffect hook that listens for keydown events on the window.

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

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

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

4. Focus Management & Trapping

This is the most critical and complex part of building an accessible modal. When a modal opens:

  1. Focus must immediately move inside the modal.
  2. The user must not be able to tab out of the modal into the background content.
  3. When the modal closes, focus must return to the exact element that triggered it.

Finding Focusable Elements

We need a helper utility to query all interactable elements within our modal container:

const FOCUSABLE_ELEMENTS = [
  '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(',');

Implementing the Focus Trap Hook

Now we wire up the focus trap logic inside our modal component, ensuring we save the previously focused element so we can restore it later.

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

export const useFocusTrap = (isOpen: boolean, onClose: () => void) => {
  const modalRef = useRef<HTMLDivElement>(null);
  const previousActiveElement = useRef<HTMLElement | null>(null);

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

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

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

    // 2. Query focusable elements
    const focusableElements = modalElement.querySelectorAll<HTMLElement>(FOCUSABLE_ELEMENTS);
    const firstElement = focusableElements[0];
    const lastElement = focusableElements[focusableElements.length - 1];

    // 3. Set initial focus
    if (firstElement) {
      firstElement.focus();
    }

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

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

    window.addEventListener('keydown', handleTabKey);

    return () => {
      window.removeEventListener('keydown', handleTabKey);
      // 5. Restore focus on close
      if (previousActiveElement.current) {
        previousActiveElement.current.focus();
      }
    };
  }, [isOpen]);

  return modalRef;
};

5. Background Inertness (aria-hidden or inert)

While aria-modal="true" signals screen readers to treat the background as secondary, screen readers can still sometimes navigate outside the modal container if the DOM nodes are not explicitly hidden or marked inert.

Modern browsers support the inert HTML attribute, which makes an element and all its descendant elements unfocusable and un-clickable, while also hiding them from assistive technologies.

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

  const rootElement = document.getElementById('root'); // or main app wrapper
  if (!rootElement) return;

  rootElement.setAttribute('inert', '');

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

(Note: For older browser support, you can fallback to manually toggling aria-hidden="true" on all sibling nodes of the portal root.)


6. Putting It All Together

Here is our complete, fully integrated, accessible Modal component:

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

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

const FOCUSABLE_SELECTORS = [
  'a[href]',
  'button:not([disabled])',
  'textarea:not([disabled])',
  'input:not([disabled])',
  'select:not([disabled])',
  '[tabindex]:not([tabindex="-1"])',
].join(',');

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

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

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

    if (modalElement) {
      const focusables = modalElement.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
      focusables[0]?.focus();
    }

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

      if (e.key === 'Tab' && modalElement) {
        const focusables = modalElement.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
        const first = focusables[0];
        const last = focusables[focusables.length - 1];

        if (e.shiftKey && document.activeElement === first) {
          last?.focus();
          e.preventDefault();
        } else if (!e.shiftKey && document.activeElement === last) {
          first?.focus();
          e.preventDefault();
        }
      }
    };

    window.addEventListener('keydown', handleKeyDown);

    return () => {
      window.removeEventListener('keydown', handleKeyDown);
      previousFocusRef.current?.focus();
    };
  }, [isOpen, onClose]);

  // Handle inert background
  useEffect(() => {
    if (!isOpen) return;
    const appRoot = document.getElementById('root');
    appRoot?.setAttribute('inert', '');
    return () => {
      appRoot?.removeAttribute('inert');
    };
  }, [isOpen]);

  if (!isOpen) return null;

  const titleId = `modal-title-${Math.random().toString(36).substr(2, 9)}`;

  return createPortal(
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
      <div
        ref={modalRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby={titleId}
        className="bg-white rounded-lg p-6 w-full max-w-md shadow-xl"
      >
        <div className="flex justify-between items-center mb-4">
          <h2 id={titleId} className="text-lg font-bold">
            {title}
          </h2>
          <button
            onClick={onClose}
            className="text-gray-500 hover:text-black font-semibold px-2 py-1"
            aria-label="Close dialog"
          >
            ✕
          </button>
        </div>
        <div>{children}</div>
      </div>
    </div>,
    document.body
  );
};

Conclusion

Building accessible user interfaces requires empathy and strict adherence to established standards like WAI-ARIA. By combining React Portals, robust keyboard event listeners, precise focus trapping, and browser-level inertness, you ensure that your modal component delivers an exceptional, seamless experience for every user—regardless of whether they navigate with a mouse, a keyboard, or a screen reader.

More posts