A focus trap keeps keyboard focus inside a container while it is open: pressing Tab past the last control loops back to the first instead of escaping to the rest of the page, and closing the trap returns focus to wherever it started. It is the piece of accessibility that makes a modal usable without a mouse — the WAI-ARIA dialog pattern requires it. Build useFocusTrap(active), a hook that returns a ref you attach to the container; while active is true, keyboard focus is confined inside it. Unlike useFocus, which focuses a single element on demand, this confines Tab across a whole subtree and restores focus on release.
function useFocusTrap(active: boolean): React.RefObject<HTMLElement>;
// attach the returned ref to the container you want to trap focus within
When active becomes true the hook moves focus into the container and traps Tab there; when it becomes false (or the component unmounts) it returns focus to the element that had it before.
function Dialog({ open, onClose }) {
const ref = useFocusTrap(open);
if (!open) return null;
return (
<div ref={ref} role="dialog" aria-modal="true">
<button onClick={onClose}>Cancel</button>
<button onClick={onClose}>Save</button>
</div>
);
}
// Opening focuses Cancel; Tab from Save wraps to Cancel; closing refocuses
// the button that opened the dialog.
// The tabbable set is computed live, skipping untabbable nodes:
// <button>Close</button> → tabbable (first)
// <input disabled /> → skipped
// <span tabindex="-1">…</span> → skipped
// <button>Save</button> → tabbable (last)
// Tab from "Save" wraps to "Close"; Shift+Tab from "Close" wraps to "Save".
active drives everything. True moves focus in and traps Tab; false (or unmount) removes the trap and restores the previously focused element. A false-from-the-start trap does nothing.disabled and tabindex="-1" elements. Filtering hidden elements (display:none, visibility:hidden, inert) needs layout measurement and is out of scope here.You will keep keyboard focus inside a container while a boolean says it is active, wrap Tab around at the edges so focus can never leave, and hand focus back to wherever it started once the trap turns off.
Open a modal with the mouse and everything looks fine. Open it with the keyboard and press Tab a few times, and focus quietly walks off the last button into the page behind the overlay — links and inputs you cannot see but can still land on. Nothing about rendering a dialog stops this, because Tab order is one global list for the whole page and the browser has no built-in way to say "keep Tab inside this box." So an accessible dialog has to manage focus in code. The WAI-ARIA dialog pattern spells out three duties: move focus in when it opens, keep focus in while it is open, and give focus back when it closes.
There is no browser primitive for "trap focus here," so you intercept the one key that moves focus: Tab. On every keydown, if focus is on the last focusable element and Tab is pressed, you cancel the browser's default move and send focus to the first element instead; if focus is on the first element and Shift+Tab is pressed, you send it to the last. That single interception is the trap. Around it sit two bookends — focus something inside on open, and refocus the original element on close — without which the same keyboard users are stranded.
The obvious version focuses the first thing it can find when the trap turns on, and calls it done:
const { useRef, useEffect } = require('react');
const FOCUSABLE = 'a[href], button, input, select, textarea, [tabindex]';
function useFocusTrap(active) {
const containerRef = useRef(null);
useEffect(() => {
if (!active) return;
const container = containerRef.current;
if (!container) return;
container.querySelectorAll(FOCUSABLE)[0]?.focus();
}, [active]);
return containerRef;
}
This does one of the three jobs and even that carelessly. There is no keydown handler, so nothing stops Tab from leaving — the whole point of a trap is missing. There is no cleanup, so closing the trap leaves focus stranded wherever it was instead of returning it to the trigger. And [0] grabs the literal first match: if that element is a disabled button or a tabindex="-1" node, focus() is a silent no-op and the user is left outside the dialog.
const { useRef, useEffect } = require('react');
// Elements that can hold keyboard focus. querySelectorAll returns them in
// document order, which stands in for tab order well enough for a trap.
const FOCUSABLE = 'a[href], button, input, select, textarea, [tabindex]';
function useFocusTrap(active) {
const containerRef = useRef(null);
useEffect(() => {
if (!active) return; // only trap while active
const container = containerRef.current;
if (!container) return; // ref not attached yet — nothing to trap
// Re-read the tabbable descendants each time we need them: the DOM inside
// the trap can change while it is open. querySelectorAll also returns
// disabled and tabindex="-1" nodes — focusable but NOT tabbable — so drop
// them here.
const getTabbables = () =>
Array.from(container.querySelectorAll(FOCUSABLE)).filter(
(el) => !el.disabled && el.getAttribute('tabindex') !== '-1',
);
// JOB 1 — ENTER. Remember who had focus, then move focus inside.
const previouslyFocused = document.activeElement;
const tabbables = getTabbables();
if (tabbables.length > 0) {
tabbables[0].focus();
} else {
// Nothing tabbable inside: focus the container itself so focus can't stay
// on the page behind. A plain element isn't focusable without a tabindex,
// so give it one first.
container.tabIndex = -1;
container.focus();
}
// JOB 2 — TRAP. On Tab at an edge, wrap to the other end and cancel the
// browser's default move so focus never leaves the container.
function onKeyDown(e) {
if (e.key !== 'Tab') return;
const items = getTabbables();
if (items.length === 0) {
e.preventDefault(); // keep focus on the container
return;
}
const first = items[0];
const last = items[items.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus(); // Shift+Tab off the first → last
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus(); // Tab off the last → first
}
// In the middle, let the browser move focus normally.
}
document.addEventListener('keydown', onKeyDown);
// JOB 3 — RESTORE. On deactivate or unmount, drop the listener and hand
// focus back. Optional chaining guards a trigger removed while trapped.
return () => {
document.removeEventListener('keydown', onKeyDown);
previouslyFocused?.focus?.();
};
}, [active]);
return containerRef;
}
module.exports = { useFocusTrap };
The effect keys on active, so it runs when the trap turns on and its cleanup runs when the trap turns off (or the component unmounts) — the two moments that bracket a trap's life. On entry it saves document.activeElement (the trigger) before moving focus in. The keydown handler listens on document, so it catches Tab wherever focus is; it only acts at the two edges and leaves middle Tabs to the browser. The cleanup both removes the listener and refocuses the saved element.
querySelectorAll gathers every candidate the selector matches, but two kinds slip through that are not keyboard-tabbable: a disabled control, and anything with tabindex="-1" (programmatically focusable, but skipped by Tab). Filtering those out leaves the real tabbable set, and its first and last members are the two boundaries the trap wraps between. The set is computed fresh at Tab time, so a control added or removed while the dialog is open is accounted for.
One honest limit: a real trap also skips elements hidden with display:none, visibility:hidden, or the inert attribute. Detecting those needs layout measurement (offsetParent or getClientRects()), which jsdom cannot compute — so this version checks disabled and tabindex, and a production trap layers the visibility check on top.
A Subscribe dialog with a Cancel button and a Subscribe button, opened from a trigger:
active flips to true. The effect runs. previouslyFocused is saved as the trigger. getTabbables() returns [Cancel, Subscribe], so Cancel (the first) receives focus — the user is now inside the dialog.document.activeElement === last, so the handler calls preventDefault() and focuses Cancel. Focus wraps to the top instead of escaping to the page.Subscribe. Focus never reaches the page behind.active flips to false. The cleanup runs: the keydown listener is removed and previouslyFocused?.focus?.() returns focus to the trigger — the user resumes exactly where they opened the dialog.aria-modal to trap Tab. aria-modal="true" tells assistive tech the background is inert; it does not stop the Tab key. You still have to intercept Tab in code — the attribute and the trap are separate jobs.<body>, so the keyboard user is silently teleported to the top of the page. Save document.activeElement on entry and refocus it on exit.disabled / tabindex="-1" filter. A disabled first button makes the entry focus() a no-op, leaving focus outside the dialog; a tabindex="-1" node grabs focus it should never receive. Filter both out of the tabbable set.document keydown listener in cleanup, every closed trap keeps intercepting Tab for the whole page. Remove the exact function you added.inert attribute and native <dialog>. Marking everything outside the dialog inert makes the browser skip it in Tab order for free, and HTMLDialogElement.showModal() traps focus and inerts the background natively — the platform's one-line version of all of this.focus-lock take a different route: they place invisible tabindex="0" sentinel nodes just before and after the container and listen for focusin, bouncing focus back when it lands on a guard. That catches focus moved by any means (screen reader, programmatic), not just the Tab key — at the cost of injecting nodes and reacting after focus has already moved, where keydown-interception cancels the move up front.tabindex ordering and filters hidden elements by measured layout; production libraries like tabbable encode dozens of these edge cases.Escape to close, role="dialog", and aria-modal — see Modal Dialog IV, which builds the whole component around exactly this focus logic.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
A focus trap keeps keyboard focus inside a container while it is open: pressing Tab past the last control loops back to the first instead of escaping to the rest of the page, and closing the trap returns focus to wherever it started. It is the piece of accessibility that makes a modal usable without a mouse — the WAI-ARIA dialog pattern requires it. Build useFocusTrap(active), a hook that returns a ref you attach to the container; while active is true, keyboard focus is confined inside it. Unlike useFocus, which focuses a single element on demand, this confines Tab across a whole subtree and restores focus on release.
function useFocusTrap(active: boolean): React.RefObject<HTMLElement>;
// attach the returned ref to the container you want to trap focus within
When active becomes true the hook moves focus into the container and traps Tab there; when it becomes false (or the component unmounts) it returns focus to the element that had it before.
function Dialog({ open, onClose }) {
const ref = useFocusTrap(open);
if (!open) return null;
return (
<div ref={ref} role="dialog" aria-modal="true">
<button onClick={onClose}>Cancel</button>
<button onClick={onClose}>Save</button>
</div>
);
}
// Opening focuses Cancel; Tab from Save wraps to Cancel; closing refocuses
// the button that opened the dialog.
// The tabbable set is computed live, skipping untabbable nodes:
// <button>Close</button> → tabbable (first)
// <input disabled /> → skipped
// <span tabindex="-1">…</span> → skipped
// <button>Save</button> → tabbable (last)
// Tab from "Save" wraps to "Close"; Shift+Tab from "Close" wraps to "Save".
active drives everything. True moves focus in and traps Tab; false (or unmount) removes the trap and restores the previously focused element. A false-from-the-start trap does nothing.disabled and tabindex="-1" elements. Filtering hidden elements (display:none, visibility:hidden, inert) needs layout measurement and is out of scope here.You will keep keyboard focus inside a container while a boolean says it is active, wrap Tab around at the edges so focus can never leave, and hand focus back to wherever it started once the trap turns off.
Open a modal with the mouse and everything looks fine. Open it with the keyboard and press Tab a few times, and focus quietly walks off the last button into the page behind the overlay — links and inputs you cannot see but can still land on. Nothing about rendering a dialog stops this, because Tab order is one global list for the whole page and the browser has no built-in way to say "keep Tab inside this box." So an accessible dialog has to manage focus in code. The WAI-ARIA dialog pattern spells out three duties: move focus in when it opens, keep focus in while it is open, and give focus back when it closes.
There is no browser primitive for "trap focus here," so you intercept the one key that moves focus: Tab. On every keydown, if focus is on the last focusable element and Tab is pressed, you cancel the browser's default move and send focus to the first element instead; if focus is on the first element and Shift+Tab is pressed, you send it to the last. That single interception is the trap. Around it sit two bookends — focus something inside on open, and refocus the original element on close — without which the same keyboard users are stranded.
The obvious version focuses the first thing it can find when the trap turns on, and calls it done:
const { useRef, useEffect } = require('react');
const FOCUSABLE = 'a[href], button, input, select, textarea, [tabindex]';
function useFocusTrap(active) {
const containerRef = useRef(null);
useEffect(() => {
if (!active) return;
const container = containerRef.current;
if (!container) return;
container.querySelectorAll(FOCUSABLE)[0]?.focus();
}, [active]);
return containerRef;
}
This does one of the three jobs and even that carelessly. There is no keydown handler, so nothing stops Tab from leaving — the whole point of a trap is missing. There is no cleanup, so closing the trap leaves focus stranded wherever it was instead of returning it to the trigger. And [0] grabs the literal first match: if that element is a disabled button or a tabindex="-1" node, focus() is a silent no-op and the user is left outside the dialog.
const { useRef, useEffect } = require('react');
// Elements that can hold keyboard focus. querySelectorAll returns them in
// document order, which stands in for tab order well enough for a trap.
const FOCUSABLE = 'a[href], button, input, select, textarea, [tabindex]';
function useFocusTrap(active) {
const containerRef = useRef(null);
useEffect(() => {
if (!active) return; // only trap while active
const container = containerRef.current;
if (!container) return; // ref not attached yet — nothing to trap
// Re-read the tabbable descendants each time we need them: the DOM inside
// the trap can change while it is open. querySelectorAll also returns
// disabled and tabindex="-1" nodes — focusable but NOT tabbable — so drop
// them here.
const getTabbables = () =>
Array.from(container.querySelectorAll(FOCUSABLE)).filter(
(el) => !el.disabled && el.getAttribute('tabindex') !== '-1',
);
// JOB 1 — ENTER. Remember who had focus, then move focus inside.
const previouslyFocused = document.activeElement;
const tabbables = getTabbables();
if (tabbables.length > 0) {
tabbables[0].focus();
} else {
// Nothing tabbable inside: focus the container itself so focus can't stay
// on the page behind. A plain element isn't focusable without a tabindex,
// so give it one first.
container.tabIndex = -1;
container.focus();
}
// JOB 2 — TRAP. On Tab at an edge, wrap to the other end and cancel the
// browser's default move so focus never leaves the container.
function onKeyDown(e) {
if (e.key !== 'Tab') return;
const items = getTabbables();
if (items.length === 0) {
e.preventDefault(); // keep focus on the container
return;
}
const first = items[0];
const last = items[items.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus(); // Shift+Tab off the first → last
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus(); // Tab off the last → first
}
// In the middle, let the browser move focus normally.
}
document.addEventListener('keydown', onKeyDown);
// JOB 3 — RESTORE. On deactivate or unmount, drop the listener and hand
// focus back. Optional chaining guards a trigger removed while trapped.
return () => {
document.removeEventListener('keydown', onKeyDown);
previouslyFocused?.focus?.();
};
}, [active]);
return containerRef;
}
module.exports = { useFocusTrap };
The effect keys on active, so it runs when the trap turns on and its cleanup runs when the trap turns off (or the component unmounts) — the two moments that bracket a trap's life. On entry it saves document.activeElement (the trigger) before moving focus in. The keydown handler listens on document, so it catches Tab wherever focus is; it only acts at the two edges and leaves middle Tabs to the browser. The cleanup both removes the listener and refocuses the saved element.
querySelectorAll gathers every candidate the selector matches, but two kinds slip through that are not keyboard-tabbable: a disabled control, and anything with tabindex="-1" (programmatically focusable, but skipped by Tab). Filtering those out leaves the real tabbable set, and its first and last members are the two boundaries the trap wraps between. The set is computed fresh at Tab time, so a control added or removed while the dialog is open is accounted for.
One honest limit: a real trap also skips elements hidden with display:none, visibility:hidden, or the inert attribute. Detecting those needs layout measurement (offsetParent or getClientRects()), which jsdom cannot compute — so this version checks disabled and tabindex, and a production trap layers the visibility check on top.
A Subscribe dialog with a Cancel button and a Subscribe button, opened from a trigger:
active flips to true. The effect runs. previouslyFocused is saved as the trigger. getTabbables() returns [Cancel, Subscribe], so Cancel (the first) receives focus — the user is now inside the dialog.document.activeElement === last, so the handler calls preventDefault() and focuses Cancel. Focus wraps to the top instead of escaping to the page.Subscribe. Focus never reaches the page behind.active flips to false. The cleanup runs: the keydown listener is removed and previouslyFocused?.focus?.() returns focus to the trigger — the user resumes exactly where they opened the dialog.aria-modal to trap Tab. aria-modal="true" tells assistive tech the background is inert; it does not stop the Tab key. You still have to intercept Tab in code — the attribute and the trap are separate jobs.<body>, so the keyboard user is silently teleported to the top of the page. Save document.activeElement on entry and refocus it on exit.disabled / tabindex="-1" filter. A disabled first button makes the entry focus() a no-op, leaving focus outside the dialog; a tabindex="-1" node grabs focus it should never receive. Filter both out of the tabbable set.document keydown listener in cleanup, every closed trap keeps intercepting Tab for the whole page. Remove the exact function you added.inert attribute and native <dialog>. Marking everything outside the dialog inert makes the browser skip it in Tab order for free, and HTMLDialogElement.showModal() traps focus and inerts the background natively — the platform's one-line version of all of this.focus-lock take a different route: they place invisible tabindex="0" sentinel nodes just before and after the container and listen for focusin, bouncing focus back when it lands on a guard. That catches focus moved by any means (screen reader, programmatic), not just the Tab key — at the cost of injecting nodes and reacting after focus has already moved, where keydown-interception cancels the move up front.tabindex ordering and filters hidden elements by measured layout; production libraries like tabbable encode dozens of these edge cases.Escape to close, role="dialog", and aria-modal — see Modal Dialog IV, which builds the whole component around exactly this focus logic.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.