All posts
17 Oct 2026

Building an Accessible Modal Dialog in React: Focus Trapping, Portals, and ARIA Patterns

A practical, step-by-step guide to building a production-ready modal dialog in React and TypeScript with robust accessibility features including focus trapping, portal rendering, and ARIA attributes.

Building an Accessible Modal Dialog in React: Focus Trapping, Portals, and ARIA Patterns

Modal dialogs are one of the most common UI components in modern web applications, yet they are notoriously difficult to implement correctly from an accessibility (a11y) standpoint. A poorly built modal can trap screen reader users, allow focus to bleed into background content, ignore keyboard navigation, and disrupt the natural DOM flow.

To build a truly production-ready modal in React and TypeScript, we need to solve three core technical challenges:

  1. Rendering context: Escaping the local DOM tree using React Portals to prevent z-index and clipping issues.
  2. Accessibility semantics: Applying proper WAI-ARIA roles, states, and properties.
  3. Keyboard interaction and focus management: Trapping focus within the modal and restoring it when closed.

Let’s dive into building a fully accessible, robust modal component from scratch.

1. The Architectural Blueprint

Before writing code, let’s establish what makes a modal accessible according to the WAI-ARIA Authoring Practices Guide (APG):

  • Role & Labelling: The container must have role="dialog", aria-modal="true", and an accessible name (aria-labelledby or aria-label).
  • Focus Management: When opened, focus must move inside the modal immediately. When closed, focus must return to the element that triggered the modal.
  • Focus Trap: Tabbing forward or backward must cycle exclusively through the interactive elements inside the modal.
  • Dismissal: Pressing the Escape key must close the modal.
  • Background Inertness: Content outside the modal should be hidden from assistive technologies.

2. Setting Up the TypeScript Interface and Portal

We will start by defining our component’s props and implementing the React Portal wrapper. Using ReactDOM.createPortal ensures our modal renders at the DOM root (typically directly inside document.body), avoiding clipping contexts created by CSS properties like overflow: hidden or transform on parent containers.

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

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

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

  return ReactDOM.createPortal(
    <div className="modal-overlay">
      {/* Modal content goes here */}
    </div>,
    document.body
  );
};

3. Implementing Focus Trapping

Focus trapping ensures that keyboard users (using the Tab and Shift + Tab keys) cannot navigate outside of the modal dialog while it is open.

To achieve this, we query all focusable elements within our modal container and intercept the keydown event on the Tab key to loop focus from the last element back to the first, and vice versa.

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

Inside our component, we capture the element that triggered the modal so we can restore focus to it upon closing, and attach our keyboard event listeners:

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

  useEffect(() => {
    if (isOpen) {
      // 1. Save current active element
      previousActiveElement.current = document.activeElement as HTMLElement;

      // 2. Focus the modal container or first focusable element
      const modalElement = modalRef.current;
      if (modalElement) {
        const focusableElements = modalElement.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
        if (focusableElements.length > 0) {
          focusableElements[0].focus();
        } else {
          modalElement.focus();
        }
      }

      // 3. Handle keydown events (Escape and Tab trapping)
      const handleKeyDown = (event: KeyboardEvent) => {
        if (event.key === 'Escape') {
          onClose();
        }

        if (event.key === 'Tab' && modalElement) {
          const focusableElements = Array.from(
            modalElement.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS)
          );

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

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

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

      document.addEventListener('keydown', handleKeyDown);

      // Cleanup: restore focus and remove listener
      return () => {
        document.removeEventListener('keydown', handleKeyDown);
        if (previousActiveElement.current) {
          previousActiveElement.current.focus();
        }
      };
    }
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  // ... render method
};

4. Applying ARIA Attributes and Structure

Now we integrate our ref and ARIA attributes into the JSX structure. We use a unique ID for the title to satisfy aria-labelledby, ensuring screen readers announce the modal’s purpose immediately upon opening.

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

  return ReactDOM.createPortal(
    <div className="modal-backdrop" onClick={onClose}>
      <div
        ref={modalRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby={titleId}
        aria-describedby={ariaDescribedBy}
        tabIndex={-1}
        className="modal-container"
        onClick={(e) => e.stopPropagation()} // Prevent backdrop click from closing when clicking inside
      >
        <header className="modal-header">
          <h2 id={titleId} className="modal-title">
            {title}
          </h2>
          <button
            type="button"
            onClick={onClose}
            aria-label="Close modal"
            className="modal-close-btn"
          >
            &times;
          </button>
        </header>
        <div className="modal-body">
          {children}
        </div>
      </div>
    </div>,
    document.body
  );

Pro-Tip on Backdrop Clicks: Notice onClick={(e) => e.stopPropagation()} on the inner container. This ensures that clicking inside the modal box doesn’t inadvertently trigger the backdrop’s click-to-close handler, while clicking outside on the backdrop still closes the modal.

5. Making the Background Inert

Screen readers and keyboard navigation can still interact with background content if it remains in the accessibility tree, even if visually covered by a backdrop. Modern browsers natively support the inert attribute, which disables interaction and removes elements from the accessibility tree.

We can enhance our component to toggle inert on the root application element while the modal is open:

useEffect(() => {
  if (isOpen) {
    const rootElement = document.getElementById('root');
    if (rootElement) {
      rootElement.setAttribute('inert', 'true');
    }
    return () => {
      if (rootElement) {
        rootElement.removeAttribute('inert');
      }
    };
  }
}, [isOpen]);

(Note: For older browser support, you can polyfill inert or manually toggle aria-hidden="true" on all sibling nodes of the portal container). cref

6. Styling the Modal

An accessible modal also requires clear visual states, particularly high-contrast focus rings for keyboard users. Here is standard CSS to style the backdrop and container:

.modal-backdrop {
  position: fixed;
  inset: 0;
  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 20px 25px -5px rgba(0, 0, 0, 0.1);
  outline: none;
}

.modal-container:focus-visible {
  ring: 2px solid #2563eb;
}

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

.modal-close-btn {
  background: transparent;
  border: none;
  font-size: 1.5rem;
  cursor: pointer;
  padding: 0.25rem 0.5rem;
}

.modal-close-btn:focus-visible {
  outline: 2px solid #2563eb;
  border-radius: 4px;
}

Conclusion

Building an accessible modal in React requires careful attention to detail beyond just styling a box in the center of the screen. By combining React Portals for correct DOM placement, rigorous focus management and trapping for keyboard navigators, robust ARIA attributes for screen readers, and the inert attribute for background isolation, you create a component that provides a seamless, inclusive experience for every user.

Always test your modals using only a keyboard (Tab, Shift+Tab, Escape) and a screen reader (such as VoiceOver on macOS or NVDA on Windows) to verify that your implementation meets real-world accessibility standards.

More posts