Lesson 51 of 80
Opening a modal and leaving focus wherever it was is the single most common accessibility bug React apps ship — here's the trap-and-restore pattern that fixes it.
15 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
When a sighted mouse user opens a modal, their eyes just move to it — no problem. A keyboard or screen-reader user has no eyes doing that work for them; their "position" in the page is wherever focus currently sits. If you render a modal on top of the page but don't move focus into it, a keyboard user is now tabbing through page content that's visually hidden behind an overlay. That's not a minor polish issue — it can make the modal completely unusable for them.
Correct modal focus has three parts: move focus into the modal when it opens, trap Tab/Shift+Tab so focus can't escape to the page behind it while it's open, and restore focus to whatever triggered the modal when it closes.
import { useEffect, useRef } from "react";
function Modal({ isOpen, onClose, children }) {
const dialogRef = useRef(null);
const triggerRef = useRef(null);
useEffect(() => {
if (!isOpen) return;
// Remember what had focus before we opened, so we can give it back.
triggerRef.current = document.activeElement;
// Move focus into the dialog.
const dialog = dialogRef.current;
const focusable = dialog.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
(focusable[0] ?? dialog).focus();
function handleKeyDown(e) {
if (e.key === "Escape") {
onClose();
return;
}
if (e.key !== "Tab") return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
// Trap: wrap Tab/Shift+Tab inside the dialog instead of letting it
// escape to the page behind the overlay.
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
// Restore focus to whatever opened the modal.
triggerRef.current?.focus();
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="overlay" role="presentation">
<div ref={dialogRef} role="dialog" aria-modal="true" tabIndex={-1}>
{children}
</div>
</div>
);
}
The effect's cleanup function is doing the "restore" half — it runs both when isOpen flips to false and when the component unmounts, which is exactly when you want focus handed back.
focus-trap-react or Radix's Dialog earn their keep once you have several dialogs, nested dialogs, or portals to worry about — not before.Everything here — document.activeElement, .focus(), Tab order — is plain browser behavior that predates React and works the same in a jQuery app. React's only job is deciding when to run that imperative logic, which is exactly what useEffect is for: reaching outside React's declarative rendering to touch the DOM directly, synchronized to a component appearing and disappearing.