npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@usefy/use-focus-trap

v0.25.1

Published

A React hook that traps keyboard focus inside a subtree (modals/dialogs) — Tab/Shift+Tab cycling, initial focus, and focus restore on close

Readme


Overview

useFocusTrap is part of the @usefy ecosystem — a collection of production-ready, TypeScript-first, SSR-safe React hooks. It traps keyboard focus inside a container so Tab can't wander off to the page behind a modal — an accessibility essential for any dialog, drawer, or popover.

While the trap is active, Tab and Shift+Tab cycle only through the container's focusable descendants (computed live on every keypress, so content that mounts/unmounts inside the dialog is always handled), focus is moved into the trap on activation, and focus is restored to wherever it was when the trap deactivates or unmounts.

It does one thing — trapping focus. It doesn't lock body scroll (that's @usefy/use-scroll-lock) and it renders nothing; you attach the returned callback ref to your own container.

Features

  • Wrap-around cyclingTab on the last element wraps to the first; Shift+Tab on the first wraps to the last
  • Live focusable set — recomputed on every Tab (never cached), with a robust selector that excludes disabled (including <fieldset disabled> descendants), hidden, inert, tabindex="-1", and invisible elements
  • Nested-trap aware — stack two traps (a dialog over a dialog) and only the topmost reacts: Escape fires once and the Tab handlers never race
  • Configurable initial focusinitialFocus accepts an element, a ref, or a getter; defaults to the first focusable element (or the container). false disables it
  • Return focus on close — restores focus to the trigger by default (returnFocus), overridable to any target or disablable
  • Escape callbackonEscape is surfaced so you close the dialog from your own state; the hook never owns open/close
  • Zero focusable elements handled — focus is pinned to the container and Tab can't escape
  • SSR-safe — no document access on the server; the ref is inert until it attaches on the client
  • StrictMode / concurrent-safe — cleanup always removes the listener and restores focus; React 18's double mount never leaks
  • TypeScript-first — full type inference and exported types
  • Tiny & tree-shakeable — published as its own package

Installation

# npm
npm install @usefy/use-focus-trap

# yarn
yarn add @usefy/use-focus-trap

# pnpm
pnpm add @usefy/use-focus-trap

Requires React 18 or 19 (peerDependencies: "react": "^18.0.0 || ^19.0.0").

Quick Start

import { useState } from "react";
import { useFocusTrap } from "@usefy/use-focus-trap";

function Dialog() {
  const [open, setOpen] = useState(false);
  // Focus is trapped while `open`, moved to the first field on open, and
  // restored to the trigger on close. Escape is surfaced so you own the state.
  const ref = useFocusTrap<HTMLDivElement>(open, {
    onEscape: () => setOpen(false),
  });

  return (
    <>
      <button onClick={() => setOpen(true)}>Open dialog</button>
      {open && (
        <div ref={ref} role="dialog" aria-modal="true" aria-label="Sign up">
          <input placeholder="Name" />
          <input placeholder="Email" />
          <button onClick={() => setOpen(false)}>Cancel</button>
          <button onClick={() => setOpen(false)}>Save</button>
        </div>
      )}
    </>
  );
}

API

useFocusTrap(active?, options?)

const ref = useFocusTrap<HTMLDivElement>(active, options);
// <div ref={ref}> …focusable content… </div>

Returns a stable callback ref to attach to the container that should contain focus.

Modal-grade "hard" trap. active defaults to true, and while active a Tab pressed anywhere on the page — even from outside the container — is pulled back into the trap. If you render two traps at once (a dialog opened over another dialog), only the most-recently activated ("topmost") one reacts to Tab and Escape; the ones beneath stay dormant until it deactivates, so Escape fires once and the two Tab handlers never fight.

| Parameter | Type | Default | Description | | --------- | ----------------------- | ------- | -------------------------------------------------------------------------------------------------- | | active | boolean | true | Whether the trap is active. When it flips false, the listener is removed and focus is restored. | | options | UseFocusTrapOptions | {} | See below. |

Options — UseFocusTrapOptions

| Option | Type | Default | Description | | -------------- | -------------------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------- | | initialFocus | FocusTarget \| false | first focusable | Where focus lands on activation. An element, ref, or getter. false means don't move focus (you place it yourself). | | returnFocus | boolean \| FocusTarget | true | Where focus returns on deactivation/unmount. true restores the element focused at activation; a target overrides it; false skips restore. | | onEscape | (event: KeyboardEvent) => void | — | Called when Escape is pressed inside the trap. The hook never manages open/close state itself. |

FocusTarget is HTMLElement | RefObject<HTMLElement | null> | (() => HTMLElement | null) — resolved at the moment focus moves.

Also exported

  • getFocusableElements(container) — the live focusable-descendants helper (in DOM order; it does not reorder for positive tabindex), reusable for roving-tabindex and custom keyboard navigation. Controls disabled directly or by an ancestor <fieldset disabled>, as well as hidden/inert/tabindex="-1"/invisible elements, are excluded.
  • Types: UseFocusTrapOptions, UseFocusTrapRef, FocusTarget.

Examples

Custom initial focus

Point initialFocus at a specific element (a ref, element, or getter):

import { useRef, useState } from "react";
import { useFocusTrap } from "@usefy/use-focus-trap";

function EditProfile() {
  const [open, setOpen] = useState(false);
  const emailRef = useRef<HTMLInputElement>(null);

  // Focus the Email field on open instead of the first field.
  const ref = useFocusTrap<HTMLDivElement>(open, {
    initialFocus: emailRef,
    onEscape: () => setOpen(false),
  });

  return open ? (
    <div ref={ref} role="dialog" aria-modal="true">
      <input defaultValue="Ada Lovelace" />
      <input ref={emailRef} defaultValue="[email protected]" />
      <button onClick={() => setOpen(false)}>Done</button>
    </div>
  ) : null;
}

Don't move or restore focus

// Place focus yourself, and leave it where it is on close.
const ref = useFocusTrap<HTMLDivElement>(open, {
  initialFocus: false,
  returnFocus: false,
});

Pair with scroll lock for a full modal

useFocusTrap deliberately does one thing. Compose it with @usefy/use-scroll-lock for a complete modal:

import { useFocusTrap } from "@usefy/use-focus-trap";
import { useScrollLock } from "@usefy/use-scroll-lock";

function Modal({ open, onClose }: { open: boolean; onClose: () => void }) {
  const ref = useFocusTrap<HTMLDivElement>(open, { onEscape: onClose });
  useScrollLock({ enabled: open });

  return open ? (
    <div ref={ref} role="dialog" aria-modal="true">
      …
    </div>
  ) : null;
}

Testing

📊 View Detailed Coverage Report (GitHub Pages) — 44 tests, 100% statement coverage.

  • useFocusTrap.test.tsx — hook behavior (initial focus: default/custom element/ref/getter/false; Tab & Shift+Tab wrap-around; from-outside recovery; zero-focusable container pinning; disabled/hidden/fieldset[disabled] exclusion; onEscape; nested/simultaneous traps — topmost-only Escape & Tab, next-trap re-activation, no stack leak on unmount; return focus on deactivate/unmount; re-activation cancels the pending restore; returnFocus false/custom; ref stability; no-op when unattached; no re-subscribe on option change; StrictMode) plus the getFocusableElements / isFocusable / isHidden / resolveFocusTarget utilities in isolation.

License

MIT © mirunamu

This package is part of the usefy monorepo.