An interview-ready modal
Focus trap, escape, return focus, no library.
IntermediateSelf
Build a modal dialog that meets accessibility requirements: focus trap, Escape to close, return focus to trigger on close. No library.
The Requirements
A modal that passes an accessibility audit needs:
- Focus trap — Tab and Shift+Tab cycle only within the modal while it’s open
- Escape key closes it — standard keyboard behavior
- Return focus — when the modal closes, focus returns to the element that opened it
- Aria attributes —
role="dialog",aria-modal="true",aria-labelledby
The Focus Trap
function useFocusTrap(ref, isOpen) { useEffect(() => { if (!isOpen) return; const el = ref.current; const focusable = el.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', ); const first = focusable[0]; const last = focusable[focusable.length - 1];
function handleKeyDown(e) { if (e.key !== "Tab") return; if (e.shiftKey) { if (document.activeElement === first) { e.preventDefault(); last.focus(); } } else { if (document.activeElement === last) { e.preventDefault(); first.focus(); } } }
el.addEventListener("keydown", handleKeyDown); first.focus(); return () => el.removeEventListener("keydown", handleKeyDown); }, [isOpen]);}Return Focus
function useReturnFocus(isOpen) { const triggerRef = useRef(null);
useEffect(() => { if (isOpen) { triggerRef.current = document.activeElement; } else { triggerRef.current?.focus(); } }, [isOpen]);}Escape to Close
useEffect(() => { if (!isOpen) return; const handleEscape = (e) => { if (e.key === "Escape") onClose(); }; document.addEventListener("keydown", handleEscape); return () => document.removeEventListener("keydown", handleEscape);}, [isOpen, onClose]);Why It Matters
A modal that breaks keyboard navigation is inaccessible to users who rely on keyboard or screen reader. These patterns are short enough to implement from scratch — reaching for a library for a modal is often over-engineering.