Skip to content
exercises
React

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:

  1. Focus trap — Tab and Shift+Tab cycle only within the modal while it’s open
  2. Escape key closes it — standard keyboard behavior
  3. Return focus — when the modal closes, focus returns to the element that opened it
  4. 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.