Focus traps for modals
When a modal opens, focus should be inside it — and stay there until the user closes it.
You open a modal — a dialog asking "Are you sure?" — and you press Tab. If focus jumps out of the modal, into the page behind it, your modal is broken. Keyboard users can now interact with stuff they can't see, while a giant overlay sits on top.
The fix is a focus trap: while the modal is open, Tab and Shift+Tab stay inside it. Focus moves between the modal's own controls only. When the user closes the modal, focus returns to whatever opened it.
This sounds elaborate. With modern HTML it's almost free; with older patterns it's a small but careful bit of JavaScript. We'll do both.
What a focus trap is
A focus trap is a region of the page where:
- Focus is moved into the region when it opens.
- Focus cycles within the region — tabbing past the last focusable element loops back to the first, and shift-tabbing past the first wraps to the last.
- The rest of the page becomes inert — unreachable by Tab, by clicks, by screen readers.
- Focus is returned to its previous owner when the region closes.
You only build one when something on the page is modal — that is, blocking. Dialogs that demand a yes/no, full-screen lightboxes, mobile menus that take over the viewport, multi-step prompts. A non-modal popover (a tooltip, a menu, a date picker that doesn't dim the page) does not need a trap. Tab should let users escape it normally.
Focus traps are dangerous when misapplied. A trap on something that looks modal but isn't (an autocomplete dropdown, a hover menu) frustrates users who can't escape. Build a trap only when the rest of the page is genuinely off-limits.
The keyboard contract
A modal owes its users a specific set of keyboard behaviors. This is the contract — meet all five and you've shipped an accessible modal.
- On open, focus moves into the modal. Usually to the first focusable element, or to the close button if there's nothing meaningful first.
- Tab moves to the next focusable element inside the modal. Past the last, it loops to the first.
- Shift+Tab moves to the previous. Past the first, it loops to the last.
- Esc closes the modal.
- On close, focus returns to the element that opened it (the trigger button).
Building one (the easy way)
The HTML <dialog> element, with .showModal(), gives you the trap and the inert backdrop for free.
<button id="open">Delete</button>
<dialog id="dlg">
<form method="dialog">
<p>Delete this item?</p>
<menu>
<button value="cancel">Cancel</button>
<button value="confirm">Delete</button>
</menu>
</form>
</dialog>
<script>
const open = document.getElementById("open");
const dlg = document.getElementById("dlg");
open.addEventListener("click", () => dlg.showModal());
// Focus return is automatic on .close().
</script>What .showModal() gives you:
- A backdrop. The rest of the page is inert and visually dimmed.
- A focus trap. Tab cycles within the dialog.
- Esc closes it (and dispatches a
cancelevent you can listen for). - Focus returns to
<button id="open">when the dialog closes.
For new code, use <dialog> unless you have a specific reason not to. It's supported everywhere modern browsers are.
Building one (manually)
When <dialog> isn't available — older browsers, design constraints, framework integrations — you build the trap by hand.
function getFocusable(root: HTMLElement) {
return Array.from(
root.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), ' +
'select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
);
}
export function useFocusTrap(open: boolean, ref: React.RefObject<HTMLElement>) {
useEffect(() => {
if (!open || !ref.current) return;
const trigger = document.activeElement as HTMLElement | null;
const focusables = getFocusable(ref.current);
focusables[0]?.focus();
function onKey(e: KeyboardEvent) {
if (e.key !== "Tab") return;
const list = getFocusable(ref.current!);
if (list.length === 0) return;
const first = list[0];
const last = list[list.length - 1];
if (e.shiftKey && document.activeElement === first) {
last.focus();
e.preventDefault();
} else if (!e.shiftKey && document.activeElement === last) {
first.focus();
e.preventDefault();
}
}
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("keydown", onKey);
trigger?.focus(); // restore focus on close
};
}, [open, ref]);
}This handles all five contract items except the inertness of the page behind the modal. For that, set inert on the rest of the page while the modal is open:
<>
<main inert={modalOpen}>...page...</main>
{modalOpen && <Modal onClose={...} />}
</>The inert attribute is the modern way to make a subtree unreachable. Older code uses aria-hidden="true" plus removing focusables; inert does both at once.
Common pitfalls
A few traps worth dodging when you build traps:
- Forgetting to restore focus. When the modal closes, focus must go back to whatever opened it — usually the trigger button. Without that, focus jumps to the document and the next Tab starts from the top.
- Focusing the wrong thing on open. The first focusable element is usually right, but for confirmation dialogs (irreversible actions), focus the Cancel button, not Confirm. That way an accidental Enter press cancels rather than commits.
- Trapping a non-modal popover. A simple menu or tooltip should let Tab take you out. Don't trap unless the user genuinely cannot do anything else.
- Forgetting Esc. The escape key is part of the contract, not optional. If your dialog can't be dismissed with Esc, keyboard users feel stuck.
- Hiding the page only visually. A blurred background looks modal but isn't. If clicks or Tab can still reach the page underneath, you have a trap that isn't.