All posts
29 Sep 2026

Trapped and Focused: Building an Accessible Modal Dialog in React from Scratch

{"title": "Trapped and Focused: Building an Accessible Modal Dialog in React from Scratch", "summary": "Learn how to build a fully accessible modal dialog in React and TypeScript with focus trapping, the inert attribute, screen reader announcements, and clean escape key handling.

{“title”: “Trapped and Focused: Building an Accessible Modal Dialog in React from Scratch”, “summary”: “Learn how to build a fully accessible modal dialog in React and TypeScript with focus trapping, the inert attribute, screen reader announcements, and clean escape key handling.”, “tags”: [“Accessibility”, “React”, “TypeScript”, “UI Components”], “body”: “## Introduction

Modal dialogs are one of the most common UI patterns on the web, yet they are notoriously difficult to get right from an accessibility (a11y) perspective. A truly accessible modal must do more than just look pretty centered on the screen; it must satisfy strict behavioral contracts for keyboard navigation, screen readers, and focus management.

When a modal opens, several things need to happen behind the scenes:

  1. Focus must move into the modal immediately.
  2. Focus must be trapped inside the modal so keyboard users cannot tab out to the background content.
  3. Background content must be hidden from assistive technologies and made inert.
  4. The Escape key must dismiss the dialog.
  5. Focus must return to the element that triggered the modal upon closing.

In this post, we will build a production-ready, highly accessible modal dialog component in React and TypeScript from scratch—no heavy third-party UI libraries required.


The Anatomy of an Accessible Modal

Before writing code, let’s understand the WAI-ARIA guidelines for dialogs (dialog). A proper modal requires specific ARIA attributes:

  • role=\"dialog\" or role=\"alertdialog\"
  • aria-modal=\"true\" to inform assistive tech that the rest of the page is blocked.
  • aria-labelledby pointing to the dialog’s title ID.
  • aria-describedby pointing to the dialog’s description or body text ID.

Step 1: Setting up the TypeScript Interface

Let’s define the props for our Modal component. We need controls for open state, dismissal handlers, labeling, and children.

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

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

Step 2: Implementing the Focus Trap

A focus trap prevents the user’s Tab and Shift + Tab keys from leaving the modal container. If a user tabs past the last focusable element in the modal, focus should cycle back to the first focusable element.

We can query all focusable elements inside our modal ref using a standard CSS selector:

const FOCUSABLE_SELECTORS = [
  'a[href]',
  'area[href]',
  'input:not([disabled])',
  'select:not([disabled])',
  'textarea:not([disabled])',
  'button:not([disabled])',
  'iframe',
  'object',
  'embed',
  '[contenteditable]',
  '[tabindex]:not([tabindex^=\"-\"])',
].join(',');

Inside our component, we capture the element that had focus before the modal opened so we can restore it later:

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

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

      // Focus the modal or its first focusable element
      const focusableElements = modalRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
      if (focusableElements && focusableElements.length > 0) {
        focusableElements[0].focus();
      }
    } else {
      // Restore focus when closing
      if (previousActiveElement.current) {
        previousActiveElement.current.focus();
      }
    }
  }, [isOpen]);

Next, we handle the keydown event to trap the tab sequence:

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

    if (event.key === 'Tab' && modalRef.current) {
      const focusableElements = modalRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
      if (focusableElements.length === 0) return;

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

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

Step 3: Utilizing the inert Attribute

Historically, hiding background content from screen readers required applying aria-hidden=\"true\" to every sibling element of the modal root. Today, modern browsers support the HTML inert attribute.

When an element is marked as inert:

  • It and all its descendants are excluded from the accessibility tree.
  • It cannot be clicked, touched, or focused.
  • Text inside it cannot be selected.

We can apply this to our application root easily:

  useEffect(() => {
    const rootElement = document.getElementById('root');
    if (!rootElement) return;

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

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

Note: For older browsers not supporting inert, you may want to fall back to a polyfill or manually toggle aria-hidden on application wrapper nodes.


Step 4: Assembling the Render Tree with Portals

To prevent CSS stacking context issues (z-index, overflow: hidden), modals should be rendered outside the standard DOM hierarchy using React Portals.

  if (!isOpen) return null;

  return ReactDOM.createPortal(
    <div className=\"modal-backdrop\" onClick={onClose}>
      <div
        ref={modalRef}
        role=\"dialog\"
        aria-modal=\"true\"
        aria-labelledby=\"modal-title\"
        onKeyDown={handleKeyDown}
        onClick={(e) => e.stopPropagation()}
        className=\"modal-content\"
        tabIndex={-1}
      >
        <div className=\"modal-header\">
          <h2 id=\"modal-title\">{title}</h2>
          <button 
            onClick={onClose} 
            aria-label=\"Close modal\"
            className=\"modal-close-btn\"
          >
            &times;
          </button>
        </div>
        <div className=\"modal-body\">
          {children}
        </div>
      </div>
    </div>,
    document.body
  );
};

Step 5: Styling for Clarity

A basic CSS setup ensures visual hierarchy, dark backdrops, and clear focus indicators:

.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-content {
  background: white;
  padding: 2rem;
  border-radius: 8px;
  max-width: 500px;
  width: 100%;
  box-shadow: 0 10px 25px rgba(0,0,0,0.2);
  outline: none; /* We manage custom focus or rely on internal elements */
}

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

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

/* Ensure high contrast focus outlines inside the modal */
.modal-content button:focus-visible,
.modal-content input:focus-visible {
  outline: 3px solid #2563eb;
  outline-offset: 2px;
}

Conclusion

Building accessible components requires thinking beyond visual design and considering how keyboard operators and screen reader users experience your application. By implementing:

  1. Focus restoration on open/close,
  2. Explicit tab trapping via vanilla JavaScript query selectors,
  3. The inert attribute to shield background DOM nodes, and
  4. Semantic ARIA roles & labels,

You ensure that your modal is robust, inclusive, and compliant with modern web standards without sacrificing developer velocity or adding unnecessary heavy dependencies.”}

More posts