Skip to content

useNavigation

standalonenavigationlist

Standalone keyboard navigation for role-based lists. Uses DOM queries to find navigable items. No provider needed.

useNavigation adds arrow-key navigation, selection, and focus tracking to any list of role-attributed elements. It is standalone — it returns an onKeyDown handler you attach to the container and needs no provider. Items are discovered from the DOM by ARIA role, so the hook stays declarative and works with whatever markup you already render.

tsx
const { highlighted, onKeyDown } = useNavigation({  containerRef,  role: "option",})

When to use it

  • Listboxes, menus, radio groups, tab bars, and command lists embedded inside a single component.
  • Cases where you control the container and can attach onKeyDown directly.
  • Copy-paste consumers that want list navigation without pulling in KeyboardProvider.

When arrow keys should work without the user first clicking into the list, or the list is the main content of a panel that must respect a scope stack, use useScopedNavigation instead. For deeper guidance see the navigation guide.

DOM contract

Three things must hold for item discovery to work:

  1. A container element with a ref passed as containerRef.
  2. Child elements with a matching role attribute.
  3. Each child carries a data-value attribute — the value the hook reports as highlighted and passes to onSelect / onEnter.

Typing data-value (TValue)

The TValue generic narrows highlighted, onHighlightChange, onSelect, and onEnter to a literal union for editor convenience — but it is a type assertion, not runtime validation. Every value originates from the data-value DOM attribute and is asserted to TValue at the DOM boundary; the hook never checks that the attribute actually belongs to your union. Instantiating with useNavigation<"a" | "b"> makes TypeScript treat the values as "a" | "b", yet a stray data-value="c" is still delivered with full type confidence. Keep your data-value attributes in sync with the union you instantiate, or validate the string inside your callback before relying on it.

Installation

$pnpm exec dgadd add keys/navigation
[Installs to]src/hooks/use-navigation.ts[Item]keys/navigation

dgadd is not public on npm yet. Until the first release, pack @diffgazer/add from the repository and install that tarball into this app, which is what puts dgadd on pnpm exec.

@diffgazer/keys requires no CSS or Tailwind setup.

Parameters

NameTypeDefaultDescription
containerRefrequiredRefObject<HTMLElement | null>Ref to the container element holding navigable items.
rolerequired"radio" | "checkbox" | "option" | "menuitem" | "menuitemcheckbox" | "menuitemradio" | "button" | "tab"ARIA role used to query navigable children within the container.
highlightedstring | nullControlled highlight value. When provided, the hook operates in controlled mode.
onHighlightChange(value: string | null) => voidCalled when the controlled highlight value should change. Receives null when highlight is cleared.
onSelect(value: string, event: KeyboardEvent) => voidCalled when Space selects the highlighted item, and as the Enter fallback when onEnter is not provided.
onEnter(value: string, event: KeyboardEvent) => voidCalled when Enter is pressed on the highlighted item. When provided, it overrides the onSelect Enter fallback.
wrapbooleantrueWrap around when reaching the first or last item.
enabledbooleantrueWhether the navigation hook is active.
preventDefaultbooleantrueCall preventDefault() on handled keyboard events.
onNavigationBoundaryReached(direction: "previous" | "next", event: KeyboardEvent, key: string) => voidCalled when the user tries to navigate past the first or last item. Receives the orientation-neutral direction, the originating keyboard event, and the key that hit the boundary.
defaultHighlightedstring | nullnullInitial highlighted value in uncontrolled mode.
upKeysstring[]Custom key names to move highlight up/left.
downKeysstring[]Custom key names to move highlight down/right.
orientation"vertical" | "horizontal""vertical"Navigation axis. Vertical uses ArrowUp/ArrowDown, horizontal uses ArrowLeft/ArrowRight.
skipDisabledbooleantrueSkip items with aria-disabled="true", data-disabled, or native disabled during navigation.
moveFocusbooleanfalseMove DOM focus to the next item instead of only updating highlight state.
scopeToContainerbooleantrueIgnore items owned by nested collection containers such as nested radiogroups, listboxes, menus, or tablists.
ownerSelectorstring | nullAdvanced owner selector override for scoping roles that do not have a standard composite owner.
itemSelectorstringNarrow the navigable items to those matching this selector, for containers whose role query is broader than their own item set.

Returns

UseNavigationReturnObject with highlight state and an onKeyDown handler to attach to the container.
NameTypeDefaultDescription
highlightedrequiredstring | nullThe value of the currently highlighted item, or null.
isHighlightedrequired(value: string) => booleanReturns true if the given value is the highlighted item.
highlightrequired(value: string | null) => voidImperatively set the highlighted item. Pass null to clear.
onKeyDownrequired(event: KeyboardEvent) => voidKeyboard event handler to attach to the container element.

Keyboard behavior

KeyBehavior
ArrowDown / ArrowUpMove highlight (vertical orientation).
ArrowRight / ArrowLeftMove highlight (horizontal orientation).
Home / EndJump to the first / last item. Not configurable.
EnterCalls onEnter(value), falling back to onSelect(value) when onEnter is omitted.
SpaceCalls onSelect(value).

Selection mirrors ARIA semantics: Space toggles, Enter activates. On every move the hook calls scrollIntoView({ block: "nearest" }) so long lists keep the focused item visible. Custom keys can be supplied with upKeys / downKeys (for example vim-style j / k).

Examples

Basic list navigation

Preview

Horizontal tab navigation

Preview

Notes

Standalone

useNavigation does not require KeyboardProvider. It works with a direct onKeyDown handler attached to the container.

DOM-based item discovery

Navigable items are queried from the DOM using the specified role or the data-diffgazer-navigation-item data contract. Items must have a data-value attribute.

Nested collections

scopeToContainer is enabled by default so items inside a nested owner container are excluded from the parent navigation order.

Controlled and uncontrolled

Pass highlighted + onHighlightChange for controlled mode, or use defaultHighlighted for uncontrolled mode.

Edge cases

  • Disabled items. With skipDisabled (default), items exposing aria-disabled="true", data-disabled, or native disabled are skipped during movement.
  • Nested collections. scopeToContainer (default) excludes items owned by a nested collection of the same family — an outer radiogroup ignores radios inside an inner radiogroup.
  • Boundaries. With wrap: false, hitting the first or last item fires onNavigationBoundaryReached(direction, event, key) instead of wrapping — useful for moving focus to an adjacent region.
  • moveFocus. By default the hook only updates highlight state. Set moveFocus to also move DOM focus to the highlighted element (common for role="tab").

Source

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