Rreact.wiki
React Snippets

Accessible Modal with Focus Trapping and Dismissal

Componentsaccessibilitymodalfocus-trapkeyboard-navigationa11y

A fully accessible React modal that traps focus, supports ESC key and click-outside dismissal, and announces state changes to screen readers.

TSX
import { useState, useEffect, useRef, useCallback } from 'react';
 
interface AccessibleModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
}
 
export function AccessibleModal({ isOpen, onClose, title, children }: AccessibleModalProps) {
  const modalRef = useRef<HTMLDivElement>(null);
  const firstFocusableRef = useRef<HTMLElement | null>(null);
  const lastFocusableRef = useRef<HTMLElement | null>(null);
 
  // Focus trapping effect
  useEffect(() => {
    if (!isOpen || !modalRef.current) return;
 
    const focusableElements = modalRef.current.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    ) as NodeListOf<HTMLElement>;
 
    firstFocusableRef.current = focusableElements[0] || modalRef.current;
    lastFocusableRef.current = focusableElements[focusableElements.length - 1] || modalRef.current;
 
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key !== 'Tab') return;
 
      if (e.shiftKey) {
        if (document.activeElement === firstFocusableRef.current) {
          e.preventDefault();
          lastFocusableRef.current?.focus();
        }
      } else {
        if (document.activeElement === lastFocusableRef.current) {
          e.preventDefault();
          firstFocusableRef.current?.focus();
        }
      }
    };
 
    const handleEscape = (e: KeyboardEvent) => {
      if (e.key === 'Escape') onClose();
    };
 
    document.addEventListener('keydown', handleKeyDown);
    document.addEventListener('keydown', handleEscape);
 
    // Initial focus
    firstFocusableRef.current?.focus();
 
    return () => {
      document.removeEventListener('keydown', handleKeyDown);
      document.removeEventListener('keydown', handleEscape);
    };
  }, [isOpen, onClose]);
 
  // Click outside to close
  const handleClickOutside = useCallback(
    (e: MouseEvent) => {
      if (!modalRef.current || !e.target) return;
      if (!modalRef.current.contains(e.target as Node)) {
        onClose();
      }
    },
    [onClose]
  );
 
  // Screen reader announcement via aria-live
  useEffect(() => {
    if (isOpen) {
      const liveRegion = document.getElementById('modal-live-region');
      if (liveRegion) {
        liveRegion.textContent = `Modal opened: ${title}`;
      }
    }
  }, [isOpen, title]);
 
  if (!isOpen) return null;
 
  return (
    <>
      <div
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        aria-describedby="modal-description"
        ref={modalRef}
        className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black bg-opacity-50"
        onClick={handleClickOutside}
      >
        <div
          className="relative w-full max-w-md rounded-lg bg-white p-6 shadow-xl"
          onClick={(e) => e.stopPropagation()}
        >
          <button
            type="button"
            aria-label="Close modal"
            onClick={onClose}
            className="absolute right-4 top-4 text-gray-500 hover:text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
          >
            <svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
            </svg>
          </button>
          <h2 id="modal-title" className="text-xl font-semibold text-gray-900 mb-2">
            {title}
          </h2>
          <div id="modal-description" className="text-gray-600">
            {children}
          </div>
        </div>
      </div>
      {/* Live region for screen reader announcements */}
      <div id="modal-live-region" aria-live="polite" aria-atomic="true" className="sr-only" />
    </>
  );
}
TSX
import { useState, useEffect, useRef, useCallback } from 'react';
 
interface AccessibleModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
}
 
export function AccessibleModal({ isOpen, onClose, title, children }: AccessibleModalProps) {
  const modalRef = useRef<HTMLDivElement>(null);
  const firstFocusableRef = useRef<HTMLElement | null>(null);
  const lastFocusableRef = useRef<HTMLElement | null>(null);
 
  // Focus trapping effect
  useEffect(() => {
    if (!isOpen || !modalRef.current) return;
 
    const focusableElements = modalRef.current.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    ) as NodeListOf<HTMLElement>;
 
    firstFocusableRef.current = focusableElements[0] || modalRef.current;
    lastFocusableRef.current = focusableElements[focusableElements.length - 1] || modalRef.current;
 
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key !== 'Tab') return;
 
      if (e.shiftKey) {
        if (document.activeElement === firstFocusableRef.current) {
          e.preventDefault();
          lastFocusableRef.current?.focus();
        }
      } else {
        if (document.activeElement === lastFocusableRef.current) {
          e.preventDefault();
          firstFocusableRef.current?.focus();
        }
      }
    };
 
    const handleEscape = (e: KeyboardEvent) => {
      if (e.key === 'Escape') onClose();
    };
 
    document.addEventListener('keydown', handleKeyDown);
    document.addEventListener('keydown', handleEscape);
 
    // Initial focus
    firstFocusableRef.current?.focus();
 
    return () => {
      document.removeEventListener('keydown', handleKeyDown);
      document.removeEventListener('keydown', handleEscape);
    };
  }, [isOpen, onClose]);
 
  // Click outside to close
  const handleClickOutside = useCallback(
    (e: MouseEvent) => {
      if (!modalRef.current || !e.target) return;
      if (!modalRef.current.contains(e.target as Node)) {
        onClose();
      }
    },
    [onClose]
  );
 
  // Screen reader announcement via aria-live
  useEffect(() => {
    if (isOpen) {
      const liveRegion = document.getElementById('modal-live-region');
      if (liveRegion) {
        liveRegion.textContent = `Modal opened: ${title}`;
      }
    }
  }, [isOpen, title]);
 
  if (!isOpen) return null;
 
  return (
    <>
      <div
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        aria-describedby="modal-description"
        ref={modalRef}
        className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black bg-opacity-50"
        onClick={handleClickOutside}
      >
        <div
          className="relative w-full max-w-md rounded-lg bg-white p-6 shadow-xl"
          onClick={(e) => e.stopPropagation()}
        >
          <button
            type="button"
            aria-label="Close modal"
            onClick={onClose}
            className="absolute right-4 top-4 text-gray-500 hover:text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
          >
            <svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
            </svg>
          </button>
          <h2 id="modal-title" className="text-xl font-semibold text-gray-900 mb-2">
            {title}
          </h2>
          <div id="modal-description" className="text-gray-600">
            {children}
          </div>
        </div>
      </div>
      {/* Live region for screen reader announcements */}
      <div id="modal-live-region" aria-live="polite" aria-atomic="true" className="sr-only" />
    </>
  );
}

Use AccessibleModal by passing isOpen, onClose, title, and children. It automatically traps focus inside the modal, supports Tab/Shift+Tab cycling, closes on ESC or clicking outside, and announces openings via an aria-live region. Ensure all interactive children are focusable (e.g., buttons, inputs); avoid non-interactive divs as primary controls. For best results, mount modals near the root (e.g., in App.tsx) to avoid DOM nesting issues affecting focus behavior.

Accessible Modal with Focus Trapping and Dismissal — react.wiki