Scopes
Isolate keyboard handlers with the @diffgazer/keys scope stack.
Try the Scoped Dialog demo to see scopes in action.
Scopes control which keyboard handlers are active at any given moment. Only handlers registered in the active scope receive events. Everything else is paused.
How the scope stack works
KeyboardProvider maintains an internal stack. It starts with one entry:
For declarative scopes pushed with useScope, the most recently mounted active scope wins. A nested confirmation dialog that mounts with its parent modal becomes active because it mounts after the modal. Imperative pushScope calls outrank declarative scopes until popped.
Implementation note: declarative scopes use React useId ordering so the provider can resolve scopes that mount in the same commit. Under SSR/hydration, React can encode deeper tree position ahead of later sibling order, so keep mutually exclusive scopes (for example, a modal and its confirm dialog) in a parent-to-child nesting relationship rather than relying on sibling branch ordering.
Handlers registered in "global" still exist while "modal" is active. They just don't fire. The moment "modal" is popped, "global" handlers resume as if nothing happened.
For colocated handlers, prefer passing the scope returned by useScope into useKey. That keeps the handler tied to its own layer instead of whatever scope happens to be active when effects commit.
When to use scopes
Scopes are for layers of UI that should capture keyboard input exclusively:
- Modal dialogs
- Command palettes
- Confirmation overlays
- Nested drawers or panels
If you have a sidebar and a main content area that are both visible and both need keyboard shortcuts, you probably don't need scopes. Use containerRef and focusWithinOnly instead.
useScope
useScope pushes the named scope on mount (or when enabled becomes true) and pops it on unmount (or when enabled becomes false). It returns the active scope name when pushed, otherwise null. It throws if there's no KeyboardProvider above it in the tree.
You can also pass null instead of a name to skip pushing without removing the call -- useful when the same component conditionally introduces a scope:
Options:
Scope lifecycle
Here's what happens when a modal opens over a page that already has global shortcuts:
Nested overlays stack naturally. A confirmation dialog inside a modal:
Stack at peak: [ global, modal, confirm ]. Closing the confirm dialog pops back to modal. Closing the modal pops back to global.
Handler priority within a scope
When multiple handlers are registered for the same hotkey in the same scope, the last-registered handler wins. Handlers are checked from end to start, and the first match returns immediately.
If handleB's component unmounts, handleA becomes the active handler for that hotkey again. No re-registration needed.
Multiple components, same scope name
Each useScope call gets a unique internal ID. Two components can push the same scope name independently:
If both are mounted, the stack looks like [ global, panel, panel ]. Unmounting PanelB removes its entry but leaves PanelA's. The scope name "panel" stays active. Handler registrations clean themselves up independently when their components unmount.
useScope vs useFocusZone scope option
useFocusZone accepts an optional scope parameter that internally calls useScope:
The difference: useFocusZone only pushes the scope when scope is provided and enabled is true. If you omit scope, no scope is pushed at all. This is the common case -- most focus zones don't need their own scope.
Use the scope option on useFocusZone when the zone represents a distinct layer (like a command palette) rather than just a region within an existing layer.
defaultPrevented skipping
KeyboardProvider checks event.defaultPrevented at the very top of its keydown handler. If some other listener already called event.preventDefault(), @diffgazer/keys skips the event entirely.
This matters when you have local onKeyDown handlers on elements:
With this, pressing Escape in the input will clear it but not trigger any @diffgazer/keys handler for Escape. The React synthetic event's preventDefault() sets defaultPrevented on the native event, and @diffgazer/keys respects that.
This is by design. If a component handles a key locally, @diffgazer/keys stays out of the way.
Error handling
Handler errors are caught and logged. They don't crash the app or break other handlers:
If a handler throws, @diffgazer/keys logs the error via console.error and returns. Other hotkeys in the same scope continue to work normally.
Common mistakes
Forgetting to conditionally enable:
Relying on implicit scope for conditional layers:
For handlers colocated with useScope, pass the returned scope into useKey. When the scope is disabled, the return value is null, and useKey skips registration instead of falling back to a parent scope.
However, if a parent component registers handlers before a child pushes a scope, those handlers stay in the parent's scope:
This is usually what you want -- each component's handlers go in the scope that's active for that component.
Handlers not firing because a lower component called preventDefault:
If a child element's onKeyDown calls preventDefault(), @diffgazer/keys won't see the event. Check for local handlers on inputs, textareas, or custom components that might be intercepting keys before they reach the window listener.