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.
Maintainers
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-liteWhy
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 beforeAPI
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 restoredOr 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 modalTrap 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). disabledform controls excluded.tabindexordering (positive tabindex first, then DOM order).<a>needshref,<audio>/<video>withcontrols,contenteditable,iframe, etc.- Radio groups (only the checked/active radio is tabbable).
- Shadow DOM traversal (open roots).
details/summarysemantics.
import { getTabbable, getFocusable, isTabbable } from "focus-trap-lite";
getTabbable(modalEl); // HTMLElement[] in tab orderFramework 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-hiddenwhile 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
fallbackFocusor the container itself (withtabindex=-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-
inertpolyfill integration.
