Focus zones
Manage keyboard navigation across multiple named UI zones with @diffgazer/keys.
Try the Focus Zones demo to see zones in action.
useFocusZone solves a specific problem: the same key should do different things depending on which panel is active.
Think of an IDE layout. Enter in the file sidebar opens a file. Enter in the editor inserts a newline. Enter in the terminal runs a command. Same key, three different handlers. Zones let you express this without manually wiring if (activePanel === "sidebar") checks everywhere.
Basic setup
Define your zones as a union type, list them out, set up transitions, and use getKeyOptions to scope your key handlers.
getKeyOptions + useKey pattern
getKeyOptions returns a UseKeyOptions object that you pass directly as the options argument to useKey. Its job is simple: set enabled to true only when the current zone matches the requested zone.
You can pass extra options as the second argument. getKeyOptions spreads them and ANDs the enabled values:
This means you can compose conditions naturally:
Without getKeyOptions, you'd write:
Same result, but getKeyOptions reads better when you have many zone-scoped handlers and it's less error-prone when zones are referenced across multiple hooks.
Transitions
Arrow key transitions define how zones connect spatially. The transitions callback receives the current zone and the arrow key pressed, and returns either a zone name or null.
Returning null declines the transition -- the key event falls through to the next handler (it does not block or consume the event). The hook also validates the return value against the zones array, so returning a string that isn't in zones is treated the same as null.
Arrow key listeners are only registered when transitions is provided. If you only use tabCycle for movement, no arrow key handlers are set up.
Tab cycling
tabCycle defines the order for Tab/Shift+Tab navigation between zones. It's independent of arrow key transitions.
Tab moves forward through the array. Shift+Tab moves backward. Both wrap around.
The tabCycle array doesn't have to include all zones, and the order doesn't have to match zones:
When every cycled zone resolves a container (via focus.targets or an explicit containerRef), Tab only cycles — and only calls preventDefault — while focus is inside one of those containers; outside them the event declines so native Tab keeps working. Without any resolvable container the cycle claims Tab document-wide.
Pass tabCycleScope: "document" to opt in to document-wide cycling even when containers are resolvable — useful for screen-level layouts where Tab always means "switch pane". Editable targets keep native Tab in this mode, so typing in an input is never trapped.
Use tabCycleBoundary with document scope when only one page region should claim Tab. If the boundary resolves to an element, Tab cycles only while focus is inside that element; outside it, native Tab proceeds. If the boundary is omitted or resolves null, document-scope cycling stays document-wide.
Controlled mode
By default, useFocusZone manages the active zone internally. Pass zone to control it externally.
In controlled mode the hook never changes the zone itself. It fires onZoneChange and expects you to update the value yourself. If you don't update zone in response to onZoneChange, the zone won't actually change.
The setZone function from the return value also works in controlled mode -- it goes through the same lifecycle callbacks (leave/enter/change) before calling onZoneChange.
Focus targets
Pass focus.targets when a zone change should also move DOM focus to that zone's root element or first interactive control.
Focus targets run after the active zone changes. Initial mount does not focus anything unless you pass autoFocus: true, which keeps implicit focus movement opt-in.
Lifecycle callbacks
Three callbacks fire during zone transitions, in this order:
onLeaveZone(currentZone)-- about to leave the current zoneonEnterZone(nextZone)-- about to enter the new zoneonZoneChange(nextZone)-- zone has changed
Setting the zone to the same value it already has is a no-op -- none of the callbacks fire.
Scope integration
The optional scope prop activates a @diffgazer/keys scope while the zone hook is active.
Internally this calls useScope("layout", { enabled }). When the zone hook is disabled (via enabled: false), the scope is also deactivated.
This is useful when zones live inside a larger scope hierarchy. For example, a three-panel layout might push a "layout" scope, and a dialog opened from within it pushes a "dialog" scope that shadows the layout's key handlers.
If scope is omitted, no scope is pushed.
Edge cases
Invalid initial zone: If initial isn't in the zones array, the hook falls back to zones[0].
Single zone: Works fine. Transitions and tab cycling become no-ops since there's nowhere to go.
Dynamic zones array: The hook checks zones.includes(currentZone) on every render. If the current zone is removed from the array, it falls back to zones[0]. This means you can add/remove zones without crashing, but the user might get unexpectedly moved.
Empty tabCycle: Tab/Shift+Tab handlers are only registered when tabCycle is provided and non-empty.
isZone with multiple arguments: isZone("sidebar", "content") returns true if the current zone is either sidebar or content. Useful for shared UI state: