Accessible Modal must trap focus, close on Escape, restore focus on close, and have correct ARIA attributes.
System design context: Modals are one of the most common yet most frequently broken accessibility patterns. A modal dialog creates a focus trap — all Tab and Shift+Tab cycles must stay within the modal while it is open. When the modal closes, focus must return to the element that triggered it.
How it works step-by-step:
aria-hidden="true" while modal is open.1import { useEffect, useRef, useCallback } from "react";23interface ModalProps {4 isOpen: boolean;5 onClose: () => void;6 title: string;7 children: React.ReactNode;8}910export function Modal({ isOpen, onClose, title, children }: ModalProps) {11 const dialogRef = useRef<HTMLDialogElement>(null);12 const previousFocusRef = useRef<HTMLElement | null>(null);1314 // Save and restore focus15 useEffect(() => {16 if (isOpen) {17 previousFocusRef.current = document.activeElement as HTMLElement;18 dialogRef.current?.showModal();19 // Focus the first focusable element inside the dialog20 const firstFocusable = dialogRef.current?.querySelector<HTMLElement>(21 "button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])"22 );23 firstFocusable?.focus();24 } else {25 dialogRef.current?.close();26 previousFocusRef.current?.focus();27 }28 }, [isOpen]);2930 const handleKeyDown = useCallback((e: React.KeyboardEvent) => {31 if (e.key === "Escape") {32 onClose();33 return;34 }3536 // Focus trap: Tab and Shift+Tab cycle within the dialog37 if (e.key === "Tab") {38 const dialog = dialogRef.current;39 if (!dialog) return;4041 const focusableElements = dialog.querySelectorAll<HTMLElement>(42 "button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])"43 );44 const firstElement = focusableElements[0];45 const lastElement = focusableElements[focusableElements.length - 1];4647 if (e.shiftKey && document.activeElement === firstElement) {48 e.preventDefault();49 lastElement.focus();50 } else if (!e.shiftKey && document.activeElement === lastElement) {51 e.preventDefault();52 firstElement.focus();53 }54 }55 }, [onClose]);5657 const handleBackdropClick = (e: React.MouseEvent) => {58 if (e.target === dialogRef.current) {59 onClose();60 }61 };6263 // Prevent body scroll when modal is open64 useEffect(() => {65 if (isOpen) {66 document.body.style.overflow = "hidden";67 return () => { document.body.style.overflow = ""; };68 }69 }, [isOpen]);7071 return (72 <dialog73 ref={dialogRef}74 onKeyDown={handleKeyDown}75 onClick={handleBackdropClick}76 aria-labelledby="modal-title"77 aria-modal="true"78 className="rounded-lg border-none shadow-xl backdrop:bg-black/50 p-0"79 >80 <div className="p-6">81 <h2 id="modal-title" className="text-xl font-bold mb-4">82 {title}83 </h2>84 {children}85 <button86 onClick={onClose}87 aria-label="Close modal"88 className="absolute top-4 right-4 p-1 hover:bg-gray-100 rounded"89 >90 <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor">91 <path d="M18 6L6 18M6 6l12 12" />92 </svg>93 </button>94 </div>95 </dialog>96 );97}9899// Usage100function App() {101 const [isOpen, setIsOpen] = useState(false);102103 return (104 <div>105 <button onClick={() => setIsOpen(true)}>Open Modal</button>106 <Modal isOpen={isOpen} onClose={() => setIsOpen(false)} title="Confirm Action">107 <p>Are you sure you want to proceed?</p>108 <div className="flex gap-2 mt-4">109 <button onClick={() => setIsOpen(false)}>Cancel</button>110 <button onClick={() => { /* confirm */ setIsOpen(false); }}>Confirm</button>111 </div>112 </Modal>113 </div>114 );115}
Key ARIA attributes:
role="dialog" (or native <dialog>) — identifies the element as a modal dialog.aria-labelledby="modal-title" — links to the dialog's title.aria-modal="true" — tells screen readers that the rest of the page is inert.<dialog> element with showModal() provides built-in focus trapping and backdrop support in modern browsers.Production pitfalls and fixes:
<dialog> with showModal().document.activeElement.document.body.style.overflow = "hidden" when open.div[role="dialog"] instead of <dialog> — native <dialog> provides free focus trapping, Escape handling, and backdrop support.Scaling pattern: For multiple modals (nested modals), use a modal stack that tracks each open modal and restores focus in reverse order.
Monitoring: Track the percentage of users who interact with the modal vs. dismiss it immediately. High dismiss rates may indicate the modal is not what users expect.