Theater stage with a single spotlight illuminating the center, surrounded by darkness

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 isolateSubtrees option (v7.7+) hides background content using inert or aria-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.