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.