Navigation
Build list, menu, and tab navigation with @diffgazer/keys.
Try the List Navigation and Tab Bar demos to see navigation in action.
Two hooks for keyboard-driven navigation. useNavigation is standalone (no Provider needed) and useScopedNavigation integrates with KeyboardProvider. Both query the DOM for items inside a container, then move focus between them with arrow keys, Home, End, Enter, and Space.
useNavigation
The main hook. Handles arrow key navigation, selection, and focus tracking for any list of role-attributed elements.
DOM setup
The listbox itself must be a keyboard entry point. Its active option stays exposed through aria-activedescendant while DOM focus remains on the listbox.
Four things need to be true about your HTML:
- A container element with a ref
- A keyboard entry point, such as
tabIndex={0}on anaria-activedescendantlistbox - Child elements with a matching
roleattribute ("option","menuitem","menuitemcheckbox","menuitemradio","radio","checkbox","button", or"tab") - Each child has a
data-valueattribute -- this is how the hook tracks which item is highlighted
Disabled items are skipped when they expose aria-disabled="true", data-disabled, or native disabled. This is the default skipDisabled behavior.
If a collection can contain another collection of the same family, keep scopeToContainer: true (the default). For example, an outer radiogroup ignores radios inside a nested radiogroup, while a listbox can still use role="group" sections for headings.
On every focus change, the hook calls scrollIntoView({ block: "nearest" }) on the focused element, so long lists scroll to keep the focused item visible.
Standalone vs scoped
useNavigation is standalone — it returns an onKeyDown handler you attach to your container. No KeyboardProvider required.
For scope-aware navigation (e.g., modals, panels), use useScopedNavigation instead. It registers keys through KeyboardProvider via useKey, so they participate in the scope stack.
When to use which:
- useNavigation: the list is embedded inside a component (combobox, dropdown, tab bar). You attach
onKeyDownto the container. - useScopedNavigation: the list is the main content of a panel, dialog, or page. You want arrow keys to work without the user clicking into the list first, and you need scope awareness.
Controlled vs uncontrolled
Uncontrolled (default): pass defaultHighlighted and the hook manages highlight state internally. highlighted reflects the internal state.
Controlled: pass highlighted and onHighlightChange. The hook calls onHighlightChange instead of updating internal state -- you own the highlighted value.
The distinction is the same as controlled/uncontrolled inputs in React. If highlighted is passed (even as null), the hook is controlled.
Selection semantics
Two keys trigger selection, and they map to different callbacks:
- Space calls
onSelect(value, event). Think "toggle" or "check". - Enter calls
onEnter(value, event). Think "activate" or "open". IfonEnteris not provided, Enter falls back toonSelect.
This matches ARIA guidelines where Space toggles and Enter activates.
Orientation
Default is "vertical" -- ArrowUp/ArrowDown navigate. Set orientation: "horizontal" for ArrowLeft/ArrowRight.
You can also override the exact keys with upKeys and downKeys:
Home always jumps to the first item, End to the last. These aren't configurable.
Boundary behavior
When the user presses down on the last item (or up on the first):
wrap: true(default): focus wraps to the other end of the listwrap: false: nothing happens, butonNavigationBoundaryReachedfires with"previous"or"next", the native keyboard event, and the key that hit the edge
This is useful for multi-section layouts where hitting the bottom of one list should move focus to the next list.
Focus tracking
Three ways to observe focus:
highlighted-- the current highlighted item'sdata-value, ornullisHighlighted(value)-- returnstrueif that value is currently highlightedonHighlightChange(value)-- callback fired on every focus change
The highlight(value) function lets you programmatically set the highlighted item:
Full options reference
Tab navigation
For tab navigation, use useNavigation with role: "tab". This provides consistent behavior with other navigation roles.
DOM setup
The container needs role="tablist". Tab buttons need role="tab" and data-value attributes. Disabled tabs use aria-disabled="true".
Arrow keys move between tabs. Home jumps to the first tab, End to the last. Wrapping is on by default.
Vertical tabs
Pass orientation: "vertical" to switch from ArrowLeft/ArrowRight to ArrowUp/ArrowDown.
Full example
Focus a tab with your mouse or Tab key, then use Left/Right arrows to move between tabs. The billing tab is skipped because it has aria-disabled="true". Home/End jump to the first and last enabled tabs.