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

sight-bind

v0.1.0

Published

Tiny, dependency-free focus trap for modals, dialogs, drawers, and menus. Traps keyboard focus, restores it on close, optional scroll lock, robust tabbable detection (incl. shadow DOM), React hook + Vue directive.

Readme

focus-trap-lite

Tiny focus trap for modals, dialogs, drawers, and menus. Keeps keyboard focus inside an element, restores it on close, optionally locks scroll, and handles all the tabbable-element edge cases for you.

npm install focus-trap-lite

Why

Accessible modals must trap focus (Tab/Shift+Tab should cycle inside, not escape to the page behind). Doing this correctly means computing the actually focusable elements (visible, not disabled, not inert, positive/zero tabindex, including shadow DOM), handling Escape, and restoring focus to the trigger on close. focus-trap-lite does all of it in a tiny package.


Quick start

import { createFocusTrap } from "focus-trap-lite";

const trap = createFocusTrap(modalElement, {
  escapeDeactivates: true,
  returnFocusOnDeactivate: true,
});

// when modal opens:
trap.activate();

// when modal closes:
trap.deactivate(); // focus returns to whatever was focused before

API

createFocusTrap(element, options?) → FocusTrap

type Options = {
  // Where focus goes on activate. default: first tabbable inside.
  initialFocus?: HTMLElement | string | (() => HTMLElement) | false;

  // Where focus returns on deactivate. default: the element focused before activate.
  setReturnFocus?: HTMLElement | string | (() => HTMLElement);
  returnFocusOnDeactivate?: boolean; // default true

  escapeDeactivates?: boolean;       // Esc closes the trap (default true)
  clickOutsideDeactivates?: boolean; // click outside closes (default false)
  allowOutsideClick?: boolean | ((e) => boolean); // let some outside clicks through

  lockScroll?: boolean;              // prevent background scroll while active (default false)
  fallbackFocus?: HTMLElement | string; // focus target if container has none

  onActivate?: () => void;
  onDeactivate?: () => void;
  onEscape?: (e: KeyboardEvent) => void;
};

type FocusTrap = {
  activate(): FocusTrap;
  deactivate(opts?: { returnFocus?: boolean }): FocusTrap;
  pause(): FocusTrap;     // temporarily stop trapping (e.g. nested trap takes over)
  unpause(): FocusTrap;
  updateContainer(el: HTMLElement | HTMLElement[]): void; // dynamic content
  readonly active: boolean;
  readonly paused: boolean;
};

Scroll lock

const trap = createFocusTrap(drawer, { lockScroll: true });
trap.activate();   // <body> scroll frozen, scrollbar-width compensated (no layout shift)
trap.deactivate(); // scroll restored

Or use it standalone:

import { lockScroll, unlockScroll } from "focus-trap-lite";
lockScroll();    // returns an unlock fn too: const unlock = lockScroll();
unlockScroll();
  • Compensates for scrollbar width to avoid the page "jumping".
  • Stacks safely (multiple modals → only unlocked when the last closes).
  • iOS Safari rubber-band scroll handled.

Multiple containers

Trap focus across more than one element (e.g. a dialog + a separate toolbar):

createFocusTrap([dialogEl, toolbarEl]).activate();

Nested traps

Open a trap from inside another (modal → confirm dialog):

const outer = createFocusTrap(modal).activate();
const inner = createFocusTrap(confirmBox).activate(); // outer auto-pauses
inner.deactivate(); // outer auto-unpauses, focus returns inside modal

Trap stack is managed automatically.


Tabbable detection (the tedious part, done right)

getTabbable(container) and getFocusable(container) are exported and handle:

  • Visible-only (skips display:none, visibility:hidden, zero-size, hidden, inert).
  • disabled form controls excluded.
  • tabindex ordering (positive tabindex first, then DOM order).
  • <a> needs href, <audio>/<video> with controls, contenteditable, iframe, etc.
  • Radio groups (only the checked/active radio is tabbable).
  • Shadow DOM traversal (open roots).
  • details/summary semantics.
import { getTabbable, getFocusable, isTabbable } from "focus-trap-lite";
getTabbable(modalEl); // HTMLElement[] in tab order

Framework usage

React hook

import { useFocusTrap } from "focus-trap-lite/react";

function Modal({ open, onClose, children }) {
  const ref = useFocusTrap(open, {
    onEscape: onClose,
    lockScroll: true,
    returnFocusOnDeactivate: true,
  });
  if (!open) return null;
  return <div role="dialog" aria-modal="true" ref={ref}>{children}</div>;
}

Vue directive

<div v-focus-trap="isOpen" role="dialog" aria-modal="true"> ... </div>

Accessibility notes

  • Pair with role="dialog" + aria-modal="true".
  • Background content should be inert / aria-hidden while a modal is open — helper included:
    import { hideOthers } from "focus-trap-lite";
    const undo = hideOthers(modalEl); // marks siblings inert/aria-hidden
    undo();
  • Returns focus to the trigger so screen-reader/keyboard users aren't dumped at the top of the page.

TypeScript

import type { FocusTrap, FocusTrapOptions } from "focus-trap-lite";

Edge cases handled

  • Container with no focusable elements → falls back to fallbackFocus or the container itself (with tabindex=-1).
  • Content that changes while open (updateContainer).
  • Element removed from DOM while active → auto-deactivates safely.
  • Multiple stacked modals + scroll lock stacking.
  • Programmatic focus changes outside the trap are pulled back in.

Roadmap

  • Solid/Svelte bindings.
  • Optional "soft" trap (warn instead of force) for non-modal popovers.
  • Auto-inert polyfill integration.