Chapter 2 of 4
Building an Accessible Modal
The parts a portal does not give you for free.
A portal solves the layout problem. Accessibility is a separate job, and a modal that skips it is unusable with a keyboard or a screen reader.
function Modal({ isOpen, onClose, title, children }) {
const dialogRef = useRef(null);
// Close on Escape
useEffect(() => {
if (!isOpen) return;
function handleKeyDown(event) {
if (event.key === "Escape") onClose();
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
// Stop the page behind from scrolling
useEffect(() => {
if (!isOpen) return;
const previous = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = previous; };
}, [isOpen]);
// Move focus into the dialog when it opens
useEffect(() => {
if (isOpen) dialogRef.current?.focus();
}, [isOpen]);
if (!isOpen) return null;
return createPortal(
<div className="overlay" onClick={onClose}>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={title}
tabIndex={-1}
className="dialog"
onClick={(event) => event.stopPropagation()}
>
<h2>{title}</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
</div>,
document.body
);
}The checklist
role="dialog"andaria-modal="true"so assistive technology announces it correctly.- Focus moves into the dialog on open, and back to the trigger on close.
- Focus is trapped inside while it is open - Tab must not reach the page behind.
- Escape closes it.
- The background does not scroll.
- A click on the overlay closes; a click inside does not.
Rendering into a specific container
// index.html: <div id="modal-root"></div>
const modalRoot = document.getElementById("modal-root");
return createPortal(children, modalRoot);