Skip to content

useFocusZone

provider-dependentfocuszones

Manage focus across multiple zones with arrow key and Tab transitions. Supports controlled and uncontrolled zone state.

useFocusZone models a layout as a set of named zones and moves focus between them on arrow keys or Tab. It tracks which zone is active, optionally drives DOM focus to a target per zone, and hands back getKeyOptions so each zone's own shortcuts only fire while that zone holds focus. It is generic over the zone type — use as const arrays for full inference of zone names.

tsx
const { zone, isZone, getKeyOptions } = useFocusZone({  initial: "sidebar",  zones: ["sidebar", "main", "footer"] as const,})

When to use it

  • A composite layout (sidebar + main, toolbar + canvas, search + results + preview) where arrow keys or Tab should jump between regions.
  • A region whose internal keyboard handlers should be gated on that region being active.
  • Two-zone row patterns — though for "content plus inline actions" rows, reach for the purpose-built useActionRowNavigation, which is built on this hook.

For movement within a single list, use useNavigation or useScopedNavigation. useFocusZone handles movement between regions. See the focus zones guide.

Installation

Requires KeyboardProvider and the @diffgazer/keys package, which is not public on npm yet.

Diffgazer packages are not yet published to npm. Until the first release, pack @diffgazer/ui and @diffgazer/keys from the repository and install those tarballs.

@diffgazer/keys requires no CSS or Tailwind setup.

Parameters

NameTypeDefaultDescription
initialrequiredTThe initial active zone when in uncontrolled mode.
zonesrequiredreadonly T[]All available zone identifiers.
zoneTControlled zone value. When provided, the hook operates in controlled mode.
onZoneChange(zone: T) => voidCalled whenever the active zone changes.
onLeaveZone(zone: T) => voidCalled when focus leaves a zone.
onEnterZone(zone: T) => voidCalled when focus enters a zone.
transitions(params: { zone: T; key: "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown" }) => T | nullCustom transition function. Return the target zone for a key press, or null to block the transition.
tabCyclereadonly T[]Zone order for Tab/Shift+Tab cycling. When omitted, Tab/Shift+Tab are not handled and follow native browser behavior.
tabCycleScope"containers" | "document""containers"Where Tab/Shift+Tab cycling claims the Tab key. "containers" cycles only while focus is inside a registered zone container and declines elsewhere so native Tab proceeds (document-wide only when no containment is resolvable). "document" cycles from anywhere in the document except editable targets, which keep native Tab.
tabCycleBoundaryFocusZoneTargetRefOptional document-scope boundary. When it resolves to an element, Tab is claimed only while focus is inside that element; outside it native Tab proceeds. No effect when omitted or null.
scopestring | nullKeyboard scope name to push while the focus zone is active. Null skips scope registration.
containerRefRefObject<HTMLElement | null>Optional DOM subtree used to scope registered focus-zone keys.
focusWithinOnlybooleanWhen true, focus-zone keys only run while focus is inside containerRef.
allowInInputbooleanWhether focus-zone keys may run when an input-like element has focus.
preventDefaultbooleanDefault preventDefault behavior inherited by transition keys and getKeyOptions helpers.
enabledbooleantrueWhether the focus zone hook is active.
focus{ targets: Partial<Record<T, FocusZoneTarget>>; autoFocus?: boolean; preventScroll?: boolean }Optional DOM focus targets for zone changes. A target may be a ref/function, or a { container, target } pair that skips focus repair while focus is already inside the container. Initial mount does not focus unless autoFocus is true.

Returns

UseFocusZoneReturn<T>Object with zone state and helpers for conditional rendering and key options.
NameTypeDefaultDescription
zonerequiredTThe currently active zone.
setZonerequired(zone: T) => voidImperatively set the active zone.
isZonerequired(...zones: T[]) => booleanReturns true if the current zone matches any of the provided zones.
getKeyOptionsrequired(zone: T, extra?: UseKeyOptions) => UseKeyOptionsReturns UseKeyOptions scoped to a specific zone, merging with optional extra options.
getZonePropsrequired(zone: T) => { "data-focused": true | undefined }Returns DOM attributes for a zone's container element. Sets data-focused when the zone is active.

Keyboard behavior

KeyBehavior
Arrow keysRun transitions({ zone, key }); return the target zone to move, or null to block. Arrow handling is inert when no transitions function is supplied.
Tab / Shift+TabCycle forward / backward through tabCycle. When tabCycle is omitted, Tab keeps native browser behavior. tabCycleScope controls where the cycle claims the key: "containers" (default) only while focus is inside a registered zone container, "document" from anywhere except editable targets. In document scope, tabCycleBoundary can limit claiming to one DOM subtree.

When the focus option is set, changing zones moves DOM focus to the zone's target (a ref, a getter, or a { container, target } pair). Initial mount does not steal focus unless autoFocus is true. getZoneProps(zone) returns data-focused for styling the active region.

The playground example registers its layout container and a focus target for every panel. Tab therefore changes both the active zone and DOM focus only while focus is inside the example; Tab elsewhere on the page remains native.

Examples

Multi-zone layout navigation

Preview

Tab-cycle regions with focus targets

Preview

Notes

Generic over zone type

useFocusZone is generic over T extends string. Use `as const` arrays for full type inference of zone names.

Controlled and uncontrolled

Pass zone + onZoneChange for controlled mode, or rely on initial for uncontrolled mode.

Scoped DOM handling

Use containerRef + focusWithinOnly when multiple keyboard regions share a page-level scope.

Requires KeyboardProvider

useFocusZone registers key bindings through the provider context and must be used within a <KeyboardProvider> tree.

Edge cases

  • Controlled mode. Pass zone + onZoneChange to own the active zone; omit zone and rely on initial for uncontrolled state.
  • Shared page scope. When several zones share one provider scope, pass containerRef + focusWithinOnly so transitions only fire while focus is inside the layout.
  • focus with { container, target }. The pair form skips focus repair while focus is already inside container, which avoids yanking focus away from a control the user is interacting with.
Info:

Hazard: tabCycle can swallow Tab document-wide. tabCycle registers window-level Tab/Shift+Tab handlers. The cycle is only scoped to the layout when the hook can resolve a containing element — either every cycled zone supplies a focus.targets container, or you pass an explicit containerRef. With containment resolvable, Tab only cycles (and only calls preventDefault) while focus is inside one of those containers; outside them it declines so native Tab keeps reaching the skip link, header controls, toasts, and other tabbable elements. If you configure tabCycle with no focus.targets containers and no containerRef, Tab is intercepted across the whole document for as long as the zone is mounted — a WCAG 2.1.1 hazard. Always anchor every cycled zone to a container.

For screen-level layouts where Tab is deliberately the "switch pane" key everywhere, opt in with tabCycleScope: "document". It makes the intercept explicit rather than accidental: Tab cycles zones from anywhere in the document while the zone's scope is active, moves focus to the active zone's target, and still leaves native Tab intact on editable targets so typing in inputs is never trapped.

Add tabCycleBoundary when document-scope cycling should only apply inside one region, such as a page's <main>. While focus is outside the resolved boundary, native Tab proceeds. Omitting the option or returning null keeps the document-wide behavior.

Source

Highlighted source loads after this disclosure opens. Browse the source repository.