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" />
</>
);
}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.