Focus and scroll
Manage focus traps, focus restoration, and scroll locking with @diffgazer/keys.
Try the Focus Trap demo to see focus trapping in action.
The three focus and scroll hooks on this page are independent of KeyboardProvider. Recipes that also call useScope or useKey still need the provider shown below.
useFocusTrap
Traps Tab focus inside a container element. Useful for modals, dialogs, and any overlay where Tab should cycle through the overlay's controls instead of escaping to the page behind it.
API
Independence from KeyboardProvider
useFocusTrap intercepts Tab/Shift+Tab with a capture-phase keydown listener on the container's document while the trap is active, and uses a capture-phase focusin listener there to recapture escaped focus. It doesn't use KeyboardContext at all. This means:
- It works without
KeyboardProviderin the tree - It doesn't interfere with
@diffgazer/keys's scope system - It doesn't care about the active scope
This separation is intentional. Focus trapping is a DOM concern. Keyboard shortcuts are an application concern. They shouldn't be coupled.
Initial focus behavior
When the trap activates, it focuses an element in this priority order:
initialFocus.currentif provided and non-null- First focusable element inside the container
- The container itself (as a fallback)
What counts as focusable
The trap uses the same focusable-element helper as the navigation utilities:
Programmatic focus targets with negative tabIndex are focusable and can be used for initialFocus. Disabled controls are excluded. Links and areas without href are excluded.
Tab cycling
Most Tab presses between tabbable elements use browser-default behavior. The trap intercepts Tab in these cases:
Tabon the last tabbable element wraps to the firstShift+Tabon the first tabbable element wraps to the last- Focus sitting on a non-tabbable element inside the container (for example a
tabIndex={-1}panel) moves to the nearest tabbable element in the Tab direction - Crossing a native radio group whose checked peer is not the next tab stop focuses that adjacent stop directly, so the trap matches native radio-group tab semantics
Outside those cases you get native tab ordering for free, and the trap steps in to prevent focus from leaving the container.
Dynamic content
Tabbable elements are re-queried on every Tab press. If you conditionally render a button or input inside the trap, it's picked up immediately -- no need to notify the trap or re-initialize anything.
Focus restoration
When the trap deactivates (unmount or enabled becomes false), it restores focus to whatever element was focused before the trap activated. This is on by default.
The modal pattern
The three hooks you'll typically combine for a modal:
useScope("modal")isolates keyboard shortcuts to the modaluseFocusTrapkeeps Tab inside the modaluseScrollLockprevents the page from scrolling behind it
All three clean up on unmount or when open becomes false.
useFocusRestore
Captures the currently focused element before temporary UI opens, then restores focus when it closes. Use it when a component owns its own open/close lifecycle and is not already using a primitive such as Dialog or CommandPalette that restores focus for you.
useFocusRestore is stack-aware. If a dialog opens another temporary surface, closing the nested surface restores focus inside the parent before the parent restores focus back to its trigger.
useScrollLock
Prevents scrolling on an element by setting overflow: hidden. Reference-counted so multiple locks on the same element don't conflict.
API
Default target
If target is omitted, the lock applies to document.body. When target is supplied but its current is null, nothing is locked until the ref is populated.
Reference counting
Multiple components can lock the same element without fighting over overflow. The hook uses a module-level WeakMap<Element, number> to track lock counts.
The original overflow value is captured when the first lock is applied and restored when the last lock is released. This means if the element had overflow: auto before, it gets overflow: auto back -- not an empty string.
WeakMap for cleanup
The WeakMap means if the element is removed from the DOM and garbage collected, its lock count goes with it. No manual cleanup, no memory leaks.
Multiple locks example
A common case: a modal and a nested confirmation dialog both lock scroll.
When ConfirmDialog unmounts, the body stays locked because Modal still has an active lock. When Modal unmounts, the body's overflow is restored.