Modals require careful attention to accessibility.
Requirements
- Focus moves to modal on open
- Focus is trapped inside
- Escape closes modal
- Focus returns to trigger on close
- Background content is inert
- Proper ARIA attributes
Complete Modal Component
jsximport { useEffect, useRef, useCallback } from 'react'; import { createPortal } from 'react-dom'; function Modal({ isOpen, onClose, title, children }) { const modalRef = useRef(null); const previousFocusRef = useRef(null); // Handle escape key const handleKeyDown = useCallback((e) => { if (e.key === 'Escape') { onClose(); } }, [onClose]); // Focus management useEffect(() => { if (isOpen) { previousFocusRef.current = document.activeElement; modalRef.current?.focus(); document.addEventListener('keydown', handleKeyDown); document.body.style.overflow = 'hidden'; } return () => { document.removeEventListener('keydown', handleKeyDown); document.body.style.overflow = ''; if (isOpen) { previousFocusRef.current?.focus(); } }; }, [isOpen, handleKeyDown]); if (!isOpen) return null; return createPortal( <div className="modal-overlay" onClick={onClose}> <div ref={modalRef} role="dialog" aria-modal="true" aria-labelledby="modal-title" tabIndex={-1} className="modal" onClick={(e) => e.stopPropagation()} > <header> <h2 id="modal-title">{title}</h2> <button onClick={onClose} aria-label="Close dialog" > × </button> </header> <div className="modal-content"> {children} </div> </div> </div>, document.body ); }
Usage
jsxfunction App() { const [isOpen, setIsOpen] = useState(false); return ( <> <button onClick={() => setIsOpen(true)}> Open Modal </button> <Modal isOpen={isOpen} onClose={() => setIsOpen(false)} title="Confirm Action" > <p>Are you sure you want to proceed?</p> <button onClick={() => setIsOpen(false)}> Confirm </button> </Modal> </> ); }
Key ARIA Attributes
jsx<div role="dialog" // Identifies as dialog aria-modal="true" // Indicates modal behavior aria-labelledby="id" // Points to title aria-describedby="id" // Points to description (optional) >