All posts
1 Oct 2026

Trap and Focus: Building an Accessible Modal Dialog in React from Scratch

Learn how to build a production-ready, fully accessible modal dialog component in React with focus trapping, escape key handling, and ARIA attributes.

Trap and Focus: Building an Accessible Modal Dialog in React from Scratch

Modal dialogs are one of the most common UI patterns in modern web applications. Whether you are building a confirmation prompt, a user settings panel, or an image lightbox, modals demand the user’s immediate attention.

Yet, despite their ubiquity, modals are notoriously difficult to implement correctly from an accessibility (a11y) standpoint. A poorly built modal traps screen readers behind the scenes, allows keyboard users to tab out into the background page, and swallows escape keys.

In this guide, we are going to build a production-ready, fully accessible modal dialog component in React and TypeScript from scratch. No third-party heavy dependencies—just pure React, robust DOM manipulation, and strict adherence to WAI-ARIA authoring practices.


The Anatomy of an Accessible Modal

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

  1. Semantic Role: The container must have role="dialog" and aria-modal="true".
  2. Labeling: It must be explicitly labeled via aria-labelledby (pointing to a header ID) or aria-label.
  3. Initial Focus: When the modal opens, focus must shift immediately to an interactive element inside the modal (usually the close button or the first form input).
  4. Focus Trap: Keyboard focus must remain trapped inside the modal while it is open. Tabbing past the last focusable element should cycle back to the first one.
  5. Escape Key Dismissal: Pressing the Escape key must close the modal.
  6. Background Inertness: Content outside the modal should be hidden from screen readers and removed from the keyboard tab order.
  7. Restoration of Focus: When the modal closes, focus must return to the exact element that triggered it.

Step 1: Setting up the TypeScript Interface

Let’s define the props for our Modal component. We want it to be flexible, accepting an isOpen state, an onClose callback, a title, and children.

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

export interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: ReactNode;
  className?: string;
}

Step 2: Managing Focus and the Escape Key

The heart of an accessible modal lies in its event listeners and DOM ref management. We need to capture the element that opened the modal so we can restore focus later, find all focusable elements inside the modal, and intercept keyboard events.

Here is how we implement the core hook logic inside our component:

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

  // 1. Save previous focus and set initial focus
  useEffect(() => {
    if (isOpen) {
      previousActiveElement.current = document.activeElement as HTMLElement;
      
      // Small timeout ensures the DOM has rendered the modal content
      const timer = setTimeout(() => {
        if (modalRef.current) {
          const focusableElements = getFocusableElements(modalRef.current);
          if (focusableElements.length > 0) {
            focusableElements[0].focus();
          }
        }
      }, 50);

      return () => clearTimeout(timer);
    } else {
      // 2. Restore focus on close
      if (previousActiveElement.current) {
        previousActiveElement.current.focus();
      }
    }
  }, [isOpen]);

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

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

      if (event.key === 'Tab' && modalRef.current) {
        const focusableElements = getFocusableElements(modalRef.current);
        if (focusableElements.length === 0) return;

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

        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);
    };
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  // ... render logic coming up
};

Helper: Finding Focusable Elements

To build our focus trap, we need a reliable way to query all interactive elements within the modal container. This includes links, buttons, inputs, selects, textareas, and any element with a positive tabindex.

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

function getFocusableElements(container: HTMLElement): HTMLElement[] {
  const elements = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
  return Array.from(elements).filter(
    (el) => el.offsetWidth > 0 || el.offsetHeight > 0 || el === document.activeElement
  );
}

Step 3: Rendering via Portals and ARIA Markup

Modals should typically live outside the main DOM hierarchy to avoid clipping issues caused by CSS properties like overflow: hidden, transform, or z-index stacking contexts. React Portals solve this perfectly.

Let’s complete our Modal component implementation with proper ARIA attributes and markup structure:

  const titleId = 'modal-title';

  return ReactDOM.createPortal(
    <div className="modal-backdrop" onClick={onClose}>
      <div
        ref={modalRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby={titleId}
        className={`modal-container ${className}`}
        onClick={(e) => e.stopPropagation()}
      >
        <div className="modal-header">
          <h2 id={titleId} className="modal-title">
            {title}
          </h2>
          <button
            type="button"
            className="modal-close-button"
            onClick={onClose}
            aria-label="Close modal"
          >
            ✕
          </button>
        </div>
        <div className="modal-body">
          {children}
        </div>
      </div>
    </div>,
    document.body
  );
};

Important Note on Backdrop Clicks: Notice onClick={(e) => e.stopPropagation()} on the modal container. This ensures that clicking inside the modal content does not accidentally trigger the backdrop’s onClose handler.


Step 4: Styling and Backdrop Inertness

To make our modal look polished and feel like a true overlay, we apply a darkened backdrop and centered container styling. We also use modern CSS features where possible.

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

.modal-container {
  background: #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;
}

.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-button {
  background: transparent;
  border: none;
  font-size: 1.25rem;
  cursor: pointer;
  color: #6b7280;
  padding: 0.25rem 0.5rem;
  border-radius: 4px;
}

.modal-close-button:hover {
  background-color: #f3f4f6;
  color: #111827;
}

Advanced: The inert Attribute

In modern browsers, you can vastly improve background screen reader accessibility by utilizing the HTML inert attribute. When applied to the root application container while a modal is open, it tells assistive technologies to ignore everything outside the modal.

// Example of toggling inert on root application node
useEffect(() => {
  const rootElement = document.getElementById('root');
  if (!rootElement) return;

  if (isOpen) {
    rootElement.setAttribute('inert', '');
  } else {
    rootElement.removeAttribute('inert');
  }

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

Conclusion

Building accessible components requires deliberate attention to keyboard navigation, screen reader semantics, and focus management. By combining React Portals, custom keydown listeners for escape and tab-cycling, and proper ARIA attributes, you have built a bulletproof modal dialog that works seamlessly for everyone.

Whenever you build complex UI primitives, always test your components using only your keyboard (Tab, Shift+Tab, and Escape) before reaching for your mouse. If you can navigate it without looking at the screen, you’ve built an accessible experience.

More posts