focus-trap: The Accessibility Bouncer Your Modals Deserve
Open a modal. Press Tab. Watch your focus cursor vanish behind the overlay into some input field on the background page that you cannot even see. Congratulations, you have just failed a basic accessibility audit. This is the problem that focus-trap has been solving for over a decade, and it does it with a lightweight elegance that has earned it 3.2 million weekly downloads and a permanent seat at the accessibility table.
focus-trap is a small, framework-agnostic vanilla JavaScript utility that traps keyboard focus within one or more DOM containers. When active, Tab and Shift+Tab cycle through focusable elements inside the trap but cannot escape. Clicks outside are blocked. Escape dismisses the trap. Focus returns to the element that triggered it. It is, in essence, the bouncer at the door of your modal, and it follows every rule in the WCAG handbook.
Why Your Modals Need a Bouncer
The case for focus trapping goes beyond good manners. WCAG 2.4.3 requires a logical focus order, and the ARIA dialog pattern mandates that focus moves into a dialog when it opens and stays there until it closes. Keyboard-only users and screen reader users depend on this behavior to navigate your interface without getting lost. Without it, your "accessible" modal is just a floating div with attitude.
focus-trap handles the full lifecycle:
- On activation: Focus moves to the first tabbable element (or a target you specify)
- While active: Tab and Shift+Tab cycle within the trap boundaries
- Click outside: Blocked by default, configurable to deactivate or allow
- Escape key: Deactivates the trap by default
- On deactivation: Focus returns to the element that had focus before activation
- Screen reader isolation: The
isolateSubtreesoption (v7.7+) hides background content usinginertoraria-hidden
Getting In
Install focus-trap for the vanilla JS core:
npm install focus-trap
or
yarn add focus-trap
For React projects, grab the official wrapper as well:
npm install focus-trap-react
The core library weighs about 5.5 KB minified and gzipped, including its single dependency tabbable, which handles the detection of focusable elements within the DOM. Tree-shakeable and side-effect-free.
Locking Down a Modal
The Vanilla Approach
The API is refreshingly simple. Import createFocusTrap, point it at a DOM node or CSS selector, and call activate:
import { createFocusTrap } from 'focus-trap';
const modal = document.getElementById('confirmation-dialog') as HTMLElement;
const trap = createFocusTrap('#confirmation-dialog', {
onActivate: () => {
modal.classList.add('is-visible');
},
onDeactivate: () => {
modal.classList.remove('is-visible');
},
escapeDeactivates: true,
clickOutsideDeactivates: true,
});
document.getElementById('open-btn')?.addEventListener('click', () => {
trap.activate();
});
document.getElementById('close-btn')?.addEventListener('click', () => {
trap.deactivate();
});
The createFocusTrap function returns a trap object with activate(), deactivate(), pause(), and unpause() methods. Every method returns the trap itself, so you can chain if the mood strikes you. The active and paused boolean properties let you check state at any time.
Controlling Initial Focus
By default, the trap focuses the first tabbable element inside the container. But sometimes the close button or a specific input should get focus instead:
const trap = createFocusTrap('#settings-panel', {
initialFocus: '#email-input',
fallbackFocus: '#settings-panel',
});
The fallbackFocus option is your safety net. If the trap contains no tabbable elements (rare, but possible with dynamically loaded content), focus needs somewhere to land. Without fallbackFocus, the trap throws an error. With it, the container itself becomes focusable as a fallback.
Preventing Scroll Jumps
When focus moves to an element that is off-screen, the browser helpfully scrolls to it. In the middle of a smooth modal open animation, this scroll jump is anything but helpful. The preventScroll option fixes that:
const trap = createFocusTrap('#slide-in-drawer', {
preventScroll: true,
initialFocus: '#drawer-heading',
});
This passes { preventScroll: true } to the underlying element.focus() call, keeping the viewport stable while focus moves.
The React Side of the Fence
Using focus-trap-react
The focus-trap-react package wraps the vanilla core in a declarative React component. At v12.0.0, it requires React 18 or later:
import { FocusTrap } from 'focus-trap-react';
interface ConfirmDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
}
function ConfirmDialog({ isOpen, onClose, onConfirm }: ConfirmDialogProps) {
if (!isOpen) return null;
return (
<FocusTrap
focusTrapOptions={{
onDeactivate: onClose,
clickOutsideDeactivates: true,
initialFocus: '#confirm-btn',
}}
>
<div className="modal-overlay">
<div role="dialog" aria-modal="true" aria-labelledby="dialog-title">
<h2 id="dialog-title">Are you sure?</h2>
<p>This action cannot be undone.</p>
<div className="button-group">
<button onClick={onClose}>Cancel</button>
<button id="confirm-btn" onClick={onConfirm}>
Confirm
</button>
</div>
</div>
</div>
</FocusTrap>
);
}
The FocusTrap component activates the trap when it mounts and deactivates on unmount. You can also control it declaratively with the active and paused props for more granular lifecycle management.
One important requirement: the child of FocusTrap must be a single DOM element (no fragments), and functional components must forward refs. If you are wrapping a custom component, make sure it uses React.forwardRef().
React 18 Strict Mode
A question that inevitably comes up: does it work with React 18 Strict Mode and its infamous double mount/unmount behavior? Yes. Since v9.0.2, focus-trap-react detects the remount and re-activates the trap automatically. One caveat: avoid using onActivate and onDeactivate callbacks to drive component state, since Strict Mode's rapid unmount-remount cycle can trigger unexpected state transitions.
Trapping the Untrapable
Multiple Containers
Sometimes the elements you want to trap focus between are not all under the same parent. A dropdown that spawns a tooltip, or a dialog with a floating toolbar -- these require focus to cycle across disjoint DOM nodes. Pass an array of containers:
const trap = createFocusTrap(
['#main-dialog', '#floating-toolbar'],
{
clickOutsideDeactivates: true,
}
);
trap.activate();
Tab will cycle through tabbable elements in both containers as if they were one continuous sequence.
Nested Traps
What happens when a modal opens another modal? focus-trap handles this with a pause/unpause mechanism backed by a shared trapStack. When the inner trap activates, the outer trap pauses. When the inner trap deactivates, the outer trap resumes:
import { createFocusTrap, FocusTrap } from 'focus-trap';
const trapStack: FocusTrap[] = [];
const outerTrap = createFocusTrap('#outer-modal', {
trapStack,
escapeDeactivates: true,
});
const innerTrap = createFocusTrap('#inner-confirmation', {
trapStack,
escapeDeactivates: true,
onDeactivate: () => {
document.getElementById('inner-confirmation')?.classList.add('hidden');
},
});
outerTrap.activate();
document.getElementById('open-inner-btn')?.addEventListener('click', () => {
document.getElementById('inner-confirmation')?.classList.remove('hidden');
innerTrap.activate();
});
The key is passing the same trapStack array to both traps. This lets them coordinate: when a new trap activates, it automatically pauses any active trap on the stack, and deactivating unpauses the previous one.
Isolating the Background
One of the more recent additions, isolateSubtrees (v7.7+), goes beyond focus trapping to hide everything outside the trap from screen readers entirely. This mirrors what the native <dialog> element does with inert:
const trap = createFocusTrap('#critical-alert', {
isolateSubtrees: 'inert',
escapeDeactivates: false,
});
Setting isolateSubtrees to 'inert' applies the inert attribute to sibling elements outside the trap. Use 'aria-hidden' if you need broader browser support. This is the difference between a modal that traps Tab presses and a modal that truly isolates itself from the rest of the page for all assistive technology.
The Native <dialog> Elephant in the Room
Modern browsers ship with the <dialog> element, and calling showModal() on it provides built-in focus management, a backdrop, and inert on background content -- for free. So why would you still reach for focus-trap?
The answer is scope. The native <dialog> handles the simple modal case beautifully, but the moment you need any of the following, you are back to needing a library:
- Custom dialog markup using
<div role="dialog">(which many component libraries still use) - Non-dialog traps like dropdown menus, sidebars, date pickers, or slide-out panels
- Multi-container traps where focusable elements span multiple DOM nodes
- Custom key bindings (arrow keys instead of Tab, for example)
- Animation coordination with Promise-based focus timing
- Nested trap management across layered UI components
If <dialog> does everything you need, use it. For everything else, there is focus-trap.
Things Worth Knowing
Safari users beware: Safari requires the "Press Tab to highlight each item on a webpage" setting to be enabled in Preferences for Tab navigation to work at all. This is not a focus-trap bug -- it is a Safari behavior that affects all focus management on the web. But it means your focus trap will appear broken for Safari users who have not enabled this setting.
The tabbable dependency is not optional. focus-trap uses tabbable to determine which elements inside the container are part of the tab order. It handles standard focusable elements, custom tabindex values, and even Shadow DOM traversal.
The v8.0.0 breaking change fixed a timing bug where onPostActivate fired before the initial focus was actually set. Since delayInitialFocus defaults to true (focus is deferred to the next frame via setTimeout(0)), the callback was too eager. If you are upgrading from v7.x, you may need to adjust tests that rely on immediate focus assertions -- wrap them in await waitFor() or similar.
Mobile is uncharted territory. The library is officially not tested on mobile browsers. Touch-based navigation does not use the Tab key, so the trapping behavior may not apply the same way on phones and tablets.
The Focus You Can Count On
In a world where accessibility is increasingly non-negotiable, focus-trap is one of those libraries that feels like infrastructure. It is small (5.5 KB), stable (10+ years and counting), actively maintained by Stefan Cameron, and trusted by millions of downloads per week. The API is approachable enough for a basic modal and flexible enough for nested multi-container traps with animation coordination and screen reader isolation.
Pair it with focus-trap-react for declarative React usage, lean on isolateSubtrees for full assistive technology isolation, and let the native <dialog> handle the simple cases. Your keyboard-only users, your screen reader users, and your next accessibility audit will all thank you.