All posts
30 Sep 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 using TypeScript, complete with focus trapping, ARIA attributes, and background inertness.

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

When building web applications, modal dialogs are among the most common UI patterns. Yet, they are also among the most frequently mishandled components when it comes to web accessibility (a11y).

A truly accessible modal is not just a centered div with a backdrop. It must:

  1. Announce itself to screen readers using correct ARIA attributes.
  2. Trap keyboard focus inside the dialog so users cannot tab into the background page.
  3. Listen for the Escape key to close gracefully.
  4. Make the background inert to assistive technologies and pointer events.
  5. Restore focus to the triggering element when closed.

In this walkthrough, we will build a robust, accessible modal component from scratch in React using TypeScript, relying entirely on native DOM APIs and zero external UI libraries.


The Anatomy of an Accessible Modal

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

  • role="dialog": Identifies the element as a dialog.
  • aria-modal="true": Informs assistive technologies that the rest of the page is modal (obscured/inert).
  • aria-labelledby: Points to the element that acts as the dialog’s title.
  • aria-describedby: Optionally points to the dialog’s description or body text.
  • Focus Management: The first focusable element inside the modal must receive focus upon opening.

Step 1: Crafting the TypeScript Component Shell

Let’s start by defining our component props. We need an isOpen flag, an onClose handler, a title, and children.

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

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

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

  // We will render via React Portal to escape parent stacking contexts
  return ReactDOM.createPortal(
    <div className="modal-overlay">
      <div
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
      >
        <h2 id="modal-title">{title}</h2>
        <div className="modal-body">{children}</div>
        <button onClick={onClose}>Close</button>
      </div>
    </div>,
    document.body
  );
};

Step 2: Implementing Focus Trapping and Restoration

When a modal opens, keyboard focus must move inside it. If a user presses Tab while on the last focusable element, focus must loop back to the first focusable element. Conversely, pressing Shift + Tab on the first element must loop to the last element.

We also need to remember which element had focus before the modal opened, so we can restore it upon closing.

Let’s add refs and keyboard event listeners:

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

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

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

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

    // 2. Find 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"])',
    ].join(',');

    const focusableElements = Array.from(
      modalElement.querySelectorAll<HTMLElement>(focusableSelectors)
    );

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

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

    // 4. Handle keyboard trapping and Escape key
    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key === 'Escape') {
        event.preventDefault();
        onClose();
      }

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

        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);

    // 5. Cleanup: restore focus when modal unmounts/closes
    return () => {
      document.removeEventListener('keydown', handleKeyDown);
      previousActiveElement.current?.focus();
    };
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return ReactDOM.createPortal(
    <div className="modal-overlay" onClick={onClose}>
      <div
        ref={modalRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        tabIndex={-1}
        onClick={(e) => e.stopPropagation()} // Prevent click-through closing
      >
        <h2 id="modal-title">{title}</h2>
        <div className="modal-body">{children}</div>
        <button type="button" onClick={onClose}>
          Close
        </button>
      </div>
    </div>,
    document.body,
    );
};

Pro-Tip: Notice tabIndex={-1} on the modal container itself. This allows the container to receive programmatic focus via modalElement.focus() in cases where the dialog body doesn’t contain standard interactive elements upon opening.


Step 3: Making the Background Inert

Preventing tab navigation inside the modal is only half the battle. Screen reader users can still use virtual cursors or touch gestures to interact with elements sitting behind the backdrop.

The modern standard for solving this is the HTML inert attribute. When applied to a DOM element, inert instructs the browser to ignore clicks, user input, and accessibility tree indexing for that element and its descendants.

Let’s update our effect hook to toggle inert on the root application container:

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

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

  previousActiveElement.current = document.activeElement as HTMLElement;
  // ... focus setup logic ...

  return () => {
    if (rootElement) {
      rootElement.removeAttribute('inert');
    }
    document.removeEventListener('keydown', handleKeyDown);
    previousActiveElement.current?.focus();
  };
}, [isOpen, onClose]);

*(Note: For legacy browser support below Chrome 102 / Safari 15.4, you may want to fall back to setting aria-hidden="true" on background siblings, though inert is widely supported in modern evergreen browsers today).*zv


Step 4: Styling and Polish

To round things out, let’s apply accessible styling. The backdrop should dim the page, and the modal container should stand out with adequate contrast and a smooth entrance animation.

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

[role="dialog"] {
  background: #ffffff;
  padding: 2rem;
  border-radius: 8px;
  max-width: 500px;
  width: 90%;
  box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
  outline: none; /* Focus outline managed via custom styles if needed */
}

[role="dialog"]:focus-visible {
  box-shadow: 0 0 0 3px #2563eb, 0 10px 25px rgba(0, 0, 0, 0.2);
}

Conclusion

Building an accessible modal dialog from scratch requires careful attention to detail, but the result is a lightweight, dependency-free component that provides an exceptional experience for keyboard and screen-reader users alike.

By combining:

  • Portals to escape parent DOM constraints,
  • ARIA attributes (role="dialog", aria-modal="true", aria-labelledby),
  • Focus trapping algorithms with array index cycling,
  • Global inert states for background isolation,

You ensure your application complies with WCAG guidelines while keeping your bundle size lean. Happy coding!

More posts