All posts
20 Sep 2026

Trapping Focus: Building a Fully Accessible Modal Dialog from Scratch in React

A deep-dive technical guide on building a fully accessible, WCAG-compliant modal dialog in React using portals, custom hooks, focus trapping, and inertness without third-party UI libraries.

Trapping Focus: Building a Fully Accessible Modal Dialog from Scratch in React

Modal dialogs are among the most common UI patterns on the modern web, yet they are notoriously difficult to implement accessibly. When a modal opens, it demands the user’s immediate attention. For sighted users, this is achieved through visual dimming and central positioning. For screen reader users and keyboard-only navigators, however, the experience relies entirely on programmatic focus management, semantic HTML, and strict keyboard event handling.

Building a robust modal without leaning on heavy third-party component libraries forces us to deeply understand browser behavior and the Web Content Accessibility Guidelines (WCAG). In this guide, we will build a production-ready, highly accessible modal dialog in React using TypeScript, React Portals, and a custom useModal hook.


The Core Requirements of an Accessible Modal

Before writing code, let’s establish the non-negotiable requirements mandated by the WAI-ARIA Authoring Practices Guide (APG):

  1. Semantic Roles & Attributes: The container must use role="dialog", aria-modal="true", and reference labels using aria-labelledby and aria-describedby.
  2. Initial Focus Management: When the modal opens, focus must instantly move to the first focusable element inside the modal (or the modal itself if no interactive elements exist).
  3. Focus Trapping: Keyboard users navigating via the Tab key must remain trapped inside the modal. Tabbing past the last focusable element must cycle back to the first, and shifting backward (Shift + Tab) from the first element must wrap around to the last.
  4. Escape Key Dismissal: Pressing the Escape key must close the modal.
  5. Background Inaccessibility (Inertness): Content outside the modal must be hidden from assistive technologies and made un-focusable.
  6. Restoration of Focus: Upon closing, focus must return precisely to the element that triggered the modal.

Step 1: Crafting the Portal and Semantic Structure

To prevent CSS clipping and z-index stacking issues, modals should render directly into a dedicated DOM node outside the main application root using ReactDOM.createPortal.

Let’s start by defining our TypeScript interfaces and building the structural component layout.

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

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

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

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

Step 2: Implementing the Focus Trap Hook

The most complex aspect of modal development is the focus trap. We can isolate this logic into a custom useFocusTrap hook. This hook will query all focusable elements within our modal container, intercept Tab key presses, and manage the focus boundaries.

Finding Focusable Elements

What constitutes a focusable element in the DOM? Anchors with href, buttons, inputs, selects, textareas, elements with a positive tabindex, and audio/video elements with controls. We can select these via a robust query selector string.

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

Now, let’s write the useFocusTrap hook:

import { useEffect, useRef } from 'react';

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

  useEffect({
    if (!isActive) return;

    // 1. Save current active element to restore focus later
    previousActiveElement.current = document.activeElement as HTMLElement;

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

    // 2. Gather focusable elements
    const focusableElements = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
    const firstElement = focusableElements[0];
    const lastElement = focusableElements[focusableElements.length - 1];

    // 3. Set initial focus
    if (firstElement) {
      firstElement.focus();
    } else {
      container.focus(); // Fallback if modal has no interactive children
    }

    // 4. Handle Tab and Shift+Tab keydown events
    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key !== 'Tab') return;

      if (focusableElements.length === 0) {
        event.preventDefault();
        return;
      }

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

    document.addEventListener('keydown', handleKeyDown);

    // 5. Cleanup: Remove listener and restore focus to trigger element
    return () => {
      document.removeEventListener('keydown', handleKeyDown);
      if (previousActiveElement.current) {
        previousActiveElement.current.focus();
      }
    };
  }, [isActive]);

  return containerRef;
}

Step 3: Handling Escape Keys and Background Inertness

Beyond trapping the tab key, screen readers and keyboard users expect the Escape key to instantly dismiss overlays. Furthermore, background content should be made completely inert so that screen readers do not read out background text.

Historically, developers achieved background inertness by toggling aria-hidden="true" on all sibling nodes of the modal root. Today, modern browsers support the native HTML inert attribute.

Let’s build a secondary hook to handle escape keys and background inertness cleanly:

import { useEffect } from 'react';

interface UseModalBehaviorsProps {
  isOpen: boolean;
  onClose: () => void;
  rootId?: string;
}

export function useModalBehaviors({ isOpen, onClose, rootId = 'root' }: UseModalBehaviorsProps) {
  useEffect(() => {
    if (!isOpen) return;

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

    window.addEventListener('keydown', handleKeyDown);

    // Handle Native Inertness for background content
    const rootElement = document.getElementById(rootId);
    if (rootElement) {
      rootElement.setAttribute('inert', '');
    }

    return () => {
      window.removeEventListener('keydown', handleKeyDown);
      if (rootElement) {
        rootElement.removeAttribute('inert');
      }
    };
  }, [isOpen, onClose, rootId]);
}

Step 4: Putting It All Together

Now we can combine our focus trap hook, our behavior hook, and our portal structure into a cohesive, highly accessible Modal component.

import React from 'react';
import ReactDOM from 'react-dom';
import { useFocusTrap } from './useFocusTrap';
import { useModalBehaviors } from './useModalBehaviors';

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

export const AccessibleModal: React.FC<ModalProps> = ({
  isOpen,
  onClose,
  title,
  children,
  descriptionId,
}) => {
  // Hook to handle focus trapping and automatic focus restoration
  const containerRef = useFocusTrap(isOpen);

  // Hook to handle Escape key dismissal and background inertness
  useModalBehaviors({ isOpen, onClose });

  if (!isOpen) return null;

  return ReactDOM.createPortal(
    <div className="modal-backdrop" role="presentation">
      <div
        ref={containerRef}
        className="modal-container"
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        aria-describedby={descriptionId}
        tabIndex={-1} // Makes container focusable as a fallback
      >
        <header className="modal-header">
          <h2 id="modal-title">{title}</h2>
          <button
            onClick={onClose}
            className="modal-close-btn"
            aria-label="Close dialog"
          >
            ✕
          </button>
        </header>
        <div className="modal-content">
          {children}
        </div>
      </div>
    </div>,
    document.body
  );
};

Styling and Visual Polish (CSS)

Accessibility is primarily structural and behavioral, but visual cues reinforce the modal state for sighted users. A proper backdrop provides clear contrast, and hiding overflow on the body prevents background scrolling.

body.modal-open {
  overflow: hidden;
}

.modal-backdrop {
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  background-color: rgba(0, 0, 0, 0.6);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
}

.modal-container {
  background: #ffffff;
  padding: 2rem;
  border-radius: 8px;
  width: 100%;
  max-width: 500px;
  box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
  outline: none; /* Focus outline managed via custom styles if desired */
}

.modal-container:focus-visible {
  box-shadow: 0 0 0 3px #2563eb, 0 10px 25px rgba(0, 0, 0, 0.2);
}

Testing Your Modal for Accessibility

Never deploy an accessibility feature without verifying it across multiple modalities. Run through this validation checklist:

  1. The Keyboard-Only Run: Unplug your mouse or trackpad. Click a button to open the modal. Press Tab repeatedly. Does focus cycle strictly within the modal? Does Shift + Tab cycle backward? Does pressing Escape close the modal and return focus directly to the opening button?
  2. Screen Reader Verification: Turn on VoiceOver (macOS) or NVDA (Windows). Open the modal. Verify that the screen reader announces the dialog title and role immediately, and ignores background elements completely.
  3. Automated Audits: Run axe DevTools or Lighthouse to catch any missing ARIA attributes or contrast issues.

Conclusion

By avoiding heavy UI frameworks for core primitives like modals, you retain total control over your application’s accessibility, performance, and bundle size. Through clean React portals, precise DOM querying in custom hooks, and native browser features like inert, you can build complex, enterprise-grade accessible components entirely from scratch.

More posts