Accessible modal with focus trapping and keyboard navigation.
Code:
1import { useEffect, useRef } from "react";23function Modal({ isOpen, onClose, children, title }) {4 const previousFocusRef = useRef<HTMLElement | null>(null);5 const modalRef = useRef<HTMLDivElement>(null);67 useEffect(() => {8 if (isOpen) {9 previousFocusRef.current = document.activeElement as HTMLElement;10 modalRef.current?.focus();11 }12 return () => {13 previousFocusRef.current?.focus();14 };15 }, [isOpen]);1617 useEffect(() => {18 const handleKeyDown = (e: KeyboardEvent) => {19 if (e.key === "Escape") onClose();2021 // Focus trap22 if (e.key === "Tab" && modalRef.current) {23 const focusable = modalRef.current.querySelectorAll(24 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'25 );26 const first = focusable[0] as HTMLElement;27 const last = focusable[focusable.length - 1] as HTMLElement;2829 if (e.shiftKey && document.activeElement === first) {30 e.preventDefault();31 last.focus();32 } else if (!e.shiftKey && document.activeElement === last) {33 e.preventDefault();34 first.focus();35 }36 }37 };3839 if (isOpen) {40 document.addEventListener("keydown", handleKeyDown);41 document.body.style.overflow = "hidden";42 }4344 return () => {45 document.removeEventListener("keydown", handleKeyDown);46 document.body.style.overflow = "";47 };48 }, [isOpen, onClose]);4950 if (!isOpen) return null;5152 return (53 <div className="modal-overlay" onClick={onClose}>54 <div55 ref={modalRef}56 className="modal"57 role="dialog"58 aria-modal="true"59 aria-labelledby="modal-title"60 onClick={e => e.stopPropagation()}61 tabIndex={-1}62 >63 <h2 id="modal-title">{title}</h2>64 {children}65 <button onClick={onClose}>Close</button>66 </div>67 </div>68 );69}
Key features: