All posts
2 Oct 2026

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

Learn how to build a production-ready, fully accessible modal dialog in React and TypeScript featuring robust focus trapping, Escape-key dismissal, focus restoration, and the modern inert attribute.

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

Modals are one of the most common UI patterns in modern web development, yet they are frequently broken for keyboard and screen reader users. When a modal opens, focus often leaks into the background content, screen readers continue to announce hidden elements, and pressing the Escape key does nothing.

To build a truly accessible modal dialog from scratch, we need to solve several technical challenges:

  1. Focus Management: Moving focus inside the modal upon opening and preventing it from escaping.
  2. Focus Restoration: Returning focus to the triggering element when the modal closes.
  3. Keyboard Interactivity: Listening for the Escape key to dismiss the dialog.
  4. Background Isolation: Utilizing the modern HTML inert attribute to hide background content from assistive technologies.
  5. Semantics: Using native semantic HTML elements (<dialog>) and ARIA attributes for screen reader compatibility.

In this post, we will build a robust, accessible modal component in React and TypeScript without relying on heavy third-party UI libraries.


The Anatomy of an Accessible Dialog

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

  • The dialog container must have role="dialog" and aria-modal="true".
  • It must have an accessible name, provided via aria-labelledby pointing to the modal’s title.
  • When open, background content must be rendered inert or hidden from assistive technology.
  • Keyboard focus must be trapped inside the modal. Tabbing forward from the last focusable element must cycle back to the first focusable element.
  • Pressing Escape must close the modal.
  • Focus must return to the element that triggered the modal upon closing.

Let’s implement these requirements step by step.


Step 1: Component Shell and TypeScript Types

Let’s start by defining our component props and basic layout using TypeScript. We’ll use a native HTML <dialog> element as our foundation, which gives us built-in semantics.

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

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

export const Modal: React.FC<ModalProps> = ({
  isOpen,
  onClose,
  title,
  children,
}) => {
  const dialogRef = useRef<HTMLDialogElement>(null);

  if (!isOpen) return null;

  return (
    <div className="modal-backdrop">
      <dialog
        ref={dialogRef}
        aria-modal="true"
        aria-labelledby="modal-title"
        className="modal-dialog"
        open
      >
        <div className="modal-header">
          <h2 id="modal-title">{title}</h2>
          <button onClick={onClose} aria-label="Close modal">
            &times;
          </button>
        </div>
        <div className="modal-body">{children}</div>
      </dialog>
    </div>
  );
};

Step 2: Preserving and Restoring Focus

When a modal opens, the user’s focus should immediately transition inside the dialog. When the modal closes, focus must return to the exact element that triggered it. Otherwise, keyboard and screen reader users will lose their place on the page.

We can capture the active element using document.activeElement right before opening, and restore it when unmounting or closing.

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

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

      // 2. Focus the dialog or its first focusable element
      const focusableElements = getFocusableElements(dialogRef.current);
      if (focusableElements.length > 0) {
        focusableElements[0].focus();
      }
    } else {
      // 3. Restore focus on close
      previousActiveElement.current?.focus();
    }
  }, [isOpen]);

  if (!isOpen) return null;

  // ... render logic
};

Helper: Finding Focusable Elements

To manage focus inside our trap, we need a helper utility that queries all focusable elements within a given container:

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

export function getFocusableElements(container: HTMLElement | null): HTMLElement[] {
  if (!container) return [];
  return Array.from(
    container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS)
  ).filter(
    (el) => !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden')
  );
}

Step 3: Implementing the Focus Trap and Escape Key Handler

If a user presses Tab while focused on the last interactive element inside the modal, focus must loop back to the first element. Conversely, Shift + Tab on the first element should loop to the last element.

We also need to listen for the Escape key globally while the modal is open.

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

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

    if (event.key === 'Tab') {
      const focusable = getFocusableElements(dialogRef.current);
      if (focusable.length === 0) return;

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

      if (event.shiftKey) {
        // Shift + Tab: if focused on first, wrap to last
        if (document.activeElement === firstElement) {
          event.preventDefault();
          lastElement.focus();
        }
      } else {
        // Tab: if focused on last, wrap to first
        if (document.activeElement === lastElement) {
          event.preventDefault();
          firstElement.focus();
        }
      }
    }
  };

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

Step 4: Securing the Background with the inert Attribute

Historically, hiding background content from screen readers and pointer events required complex combinations of aria-hidden="true" applied to all sibling nodes of the root app container.

Today, modern browsers support the inert attribute. When an element is marked as inert:

  • The browser removes it and all its descendants from the accessibility tree.
  • It ignores click and touch events.
  • It removes all nested elements from tab navigation.

We can easily toggle inert on our root application element (#root or main) whenever our modal opens:

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

  // Assuming your React app is mounted in an element with id="root"
  const rootElement = document.getElementById('root');
  if (rootElement) {
    rootElement.setAttribute('inert', '');
  }

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

Note for older browsers: While inert is supported in all modern evergreen browsers (Chrome 105+, Safari 15.4+, Firefox 113+), if you need to support legacy browsers, consider polyfilling inert using w3c/inert.


Step 5: Putting It All Together

Here is the complete, integrated React TypeScript modal component incorporating focus trapping, keyboard navigation, focus restoration, and the inert attribute:

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

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

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

function getFocusableElements(container: HTMLElement | null): HTMLElement[] {
  if (!container) return [];
  return Array.from(
    container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS)
  ).filter(
    (el) => !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden')
  );
}

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

  // Handle focus storage, restoration, and inert attribute
  useEffect(() => {
    if (isOpen) {
      previousActiveElement.current = document.activeElement as HTMLElement;

      const rootElement = document.getElementById('root');
      if (rootElement) rootElement.setAttribute('inert', '');

      const focusable = getFocusableElements(dialogRef.current);
      if (focusable.length > 0) {
        focusable[0].focus();
      }

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

  // Handle keyboard events (Escape and Tab trapping)
  useEffect(() => {
    if (!isOpen) return;

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

      if (event.key === 'Tab') {
        const focusable = getFocusableElements(dialogRef.current);
        if (focusable.length === 0) return;

        const first = focusable[0];
        const last = focusable[focusable.length - 1];

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

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

  if (!isOpen) return null;

  return (
    <div className="modal-backdrop" onClick={onClose}>
      <div
        className="modal-positioner"
        onClick={(e) => e.stopPropagation()} // Prevent backdrop clicks from closing immediately if desired
      >
        <dialog
          ref={dialogRef}
          open
          role="dialog"
          aria-modal="true"
          aria-labelledby="modal-title"
          className="modal-content"
        >
          <div className="modal-header">
            <h2 id="modal-title">{title}</h2>
            <button
              type="button"
              onClick={onClose}
              aria-label="Close modal"
              className="modal-close-btn"
            >
              &times;
            </button>
          </div>
          <div className="modal-body">{children}</div>
        </dialog>
      </div>
    </div>
  );
};

Conclusion

Building an accessible modal dialog requires attention to detail beyond mere visual styling. By combining semantic markup (role="dialog", aria-modal="true"), programmatic focus management, keyboard event listeners (Escape and Tab wrapping), and the modern inert attribute, you ensure that every user—regardless of whether they use a mouse, screen reader, or keyboard—has a seamless experience.

More posts