Skip to content

Focus zones

Manage keyboard navigation across multiple named UI zones with @diffgazer/keys.

Info:

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.

tsx
import { KeyboardProvider, useFocusZone, useKey } from "@diffgazer/keys";

type Zone = "sidebar" | "content" | "preview";

function App() {
  return (
    <KeyboardProvider>
      <ThreePanelLayout />
    </KeyboardProvider>
  );
}

function ThreePanelLayout() {
  const { zone, getKeyOptions, isZone } = useFocusZone<Zone>({
    initial: "sidebar",
    zones: ["sidebar", "content", "preview"],
    transitions: ({ zone, key }) => {
      if (key === "ArrowRight") {
        if (zone === "sidebar") return "content";
        if (zone === "content") return "preview";
      }
      if (key === "ArrowLeft") {
        if (zone === "preview") return "content";
        if (zone === "content") return "sidebar";
      }
      return null; // decline — key falls through
    },
    tabCycle: ["sidebar", "content", "preview"],
  });

  // Each useKey only fires when its zone is active
  useKey("Enter", () => openFile(), getKeyOptions("sidebar"));
  useKey("Enter", () => editLine(), getKeyOptions("content"));
  useKey("Enter", () => runPreview(), getKeyOptions("preview"));

  return (
    <div>
      <Sidebar active={isZone("sidebar")} />
      <Content active={isZone("content")} />
      <Preview active={isZone("preview")} />
    </div>
  );
}

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.

ts
getKeyOptions("sidebar")
// returns: { enabled: true }   -- when zone is "sidebar"
// returns: { enabled: false }  -- when zone is anything else

You can pass extra options as the second argument. getKeyOptions spreads them and ANDs the enabled values:

ts
getKeyOptions("sidebar", { preventDefault: true, enabled: isOpen })
// returns: { preventDefault: true, enabled: zone === "sidebar" && isOpen }

This means you can compose conditions naturally:

tsx
useKey("Enter", handleOpen, getKeyOptions("sidebar", { enabled: hasSelection }));
// Enter only fires when: in sidebar zone AND hasSelection is true

Without getKeyOptions, you'd write:

tsx
useKey("Enter", handleOpen, { enabled: zone === "sidebar" && hasSelection });

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.

tsx
transitions: ({ zone, key }) => {
  if (zone === "sidebar" && key === "ArrowRight") return "content";
  if (zone === "content" && key === "ArrowLeft") return "sidebar";
  if (zone === "content" && key === "ArrowRight") return "preview";
  if (zone === "preview" && key === "ArrowLeft") return "content";
  return 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.

tsx
useFocusZone({
  initial: "sidebar",
  zones: ["sidebar", "content", "preview"],
  tabCycle: ["sidebar", "content", "preview"],
});

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:

tsx
// Tab only cycles between sidebar and content, skipping preview
tabCycle: ["sidebar", "content"],

// Or reverse the tab order
tabCycle: ["content", "sidebar"],

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.

tsx
const [activeZone, setActiveZone] = useState<Zone>("sidebar");

const { getKeyOptions, isZone } = useFocusZone<Zone>({
  initial: "sidebar", // still needed as a type hint, but zone prop takes precedence
  zones: ["sidebar", "content", "preview"],
  zone: activeZone,
  onZoneChange: setActiveZone,
  transitions: ({ zone, key }) => {
    // ...
  },
});

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.

tsx
const listRef = useRef<HTMLDivElement>(null);
const detailsRef = useRef<HTMLDivElement>(null);

useFocusZone({
  initial: "list",
  zones: ["list", "details"],
  focus: {
    targets: {
      list: listRef,
      details: detailsRef,
    },
  },
});

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:

  1. onLeaveZone(currentZone) -- about to leave the current zone
  2. onEnterZone(nextZone) -- about to enter the new zone
  3. onZoneChange(nextZone) -- zone has changed
tsx
useFocusZone({
  initial: "sidebar",
  zones: ["sidebar", "content", "preview"],
  onLeaveZone: (zone) => saveState(zone),
  onEnterZone: (zone) => restoreState(zone),
  onZoneChange: (zone) => trackAnalytics(zone),
  // ...
});

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.

tsx
useFocusZone({
  initial: "sidebar",
  zones: ["sidebar", "content"],
  scope: "layout",
  // ...
});

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:

tsx
const showEditor = isZone("content", "preview");