Installation

Install keyrove from npm and add arrow-key navigation in vanilla JavaScript, React, Vue or Svelte, with typed attribute builders or hand-written attributes.

Install

pnpm add @mixedrays/keyrove

The package is ESM-only, ships its own types, and has no runtime dependencies.

Vanilla

Attach one listener to the container. The container is the navigation root, so the item query is scoped to it automatically.

import { keyRove } from '@mixedrays/keyrove';

const list = document.querySelector('#menu');
list.addEventListener('keydown', (e) => keyRove(e));

Give non-native items tabindex="0" so they can receive focus. With the default bindings, Tab visits each item. For one tab stop per group, use roving tabindex; the complete roving setup below puts every piece together.

Lists use ↑/↓ by default. Set data-keyrove-next-key and data-keyrove-prev-key on the root to change the keys. Attribute names are also exported as constants.

Use options when you cannot add attributes to the markup. For example, keyRove(e, { items: '[role="menuitem"]' }) selects items by their role. See attributes and options for the mapping and fallback rules.

React

Use rootAttributes and itemAttributes for typed settings in markup. They include the root and item markers, and turn booleans into "true" or "false". Hand-written attributes work too.

import { itemAttributes, keyRove, rootAttributes } from '@mixedrays/keyrove';

export const Menu = ({ items, loop = false }) => (
  <ul {...rootAttributes({ loop })} onKeyDown={keyRove}>
    {items.map((item) => (
      <li key={item.id} {...itemAttributes()} tabIndex={0}>
        {item.label}
      </li>
    ))}
  </ul>
);

React's SyntheticEvent satisfies the shape keyRove needs, so it can be passed as the handler directly.

Vue

<script setup lang="ts">
import { itemAttributes, keyRove, rootAttributes } from '@mixedrays/keyrove';

defineProps<{ items: { id: string; label: string }[]; loop?: boolean }>();
</script>

<template>
  <ul v-bind="rootAttributes({ loop })" @keydown="keyRove">
    <li
      v-for="item in items"
      :key="item.id"
      v-bind="itemAttributes()"
      tabindex="0"
    >
      {{ item.label }}
    </li>
  </ul>
</template>

Svelte

<script lang="ts">
  import { itemAttributes, keyRove, rootAttributes } from '@mixedrays/keyrove';

  let { items, loop = false }: {
    items: { id: string; label: string }[];
    loop?: boolean;
  } = $props();
</script>

<ul {...rootAttributes({ loop })} onkeydown={keyRove}>
  {#each items as item (item.id)}
    <li {...itemAttributes()} tabindex="0">{item.label}</li>
  {/each}
</ul>

Several groups, one listener

Mark each group with data-keyrove-root to use one listener on a shared panel or document. Each event uses the nearest root at or above its target, falling back to the listener's element when no root is marked. Sibling roots keep their items and settings separate.

<div id="panel">
  <ul data-keyrove-root>
    <li data-keyrove-item tabindex="0">Inbox</li>
    <li data-keyrove-item tabindex="0">Drafts</li>
  </ul>

  <!-- A label picker in the same panel: its own root, its own columns. -->
  <ul data-keyrove-root data-keyrove-cols="4">
    <li data-keyrove-item tabindex="0">Work</li>
    <li data-keyrove-item tabindex="0">Travel</li>
  </ul>
</div>

Roots can also nest. Each inner group uses its own keys and settings; see nested roots.

Complete roving setup

A group with one tab stop combines four pieces: navigation, an initial tab stop, focus tracking and, optionally, typeahead. This recipe wires them to one configuration object:

<div id="choices">
  <button>Inbox</button>
  <button>Drafts</button>
  <button>Sent</button>
</div>
  • initRovingTabindex gives the first button tabindex="0" and the others -1, so Tab enters the group once.
  • keyRove moves focus with the arrows, Home, End and the page keys, and carries the tab stop with each move.
  • followFocus moves the tab stop when focus arrives another way: a click, element.focus(), or Tab onto a control inside an item.
  • typeahead focuses the first button whose label starts with the typed text. Without typeahead, remove createTypeahead from the import, the typeahead line and || typeahead(e).

Every helper receives the same config, so they agree on which elements are items and that the group has one tab stop.

After rendering

Call initRovingTabindex(list, config) again after a render that may add, remove or replace items. It keeps a stop that is still valid and repairs a missing one. The listeners stay attached to list, so add them once, outside the render. In a framework, make the call from the hook that runs after each render.

Choosing the first stop

Pass initial when your code knows which item should start with the stop, such as the one your app shows as selected:

initRovingTabindex(list, {
  ...config,
  initial: list.querySelector('.selected'),
});

initial overrides the current stop. Use it on first setup or when your app changes the selection, and leave it out of the post-render call; otherwise every render sends the stop back and the user loses their place.

This recipe covers focus movement only. Roles, labels and selection belong to the widget; the listbox adds them to these pieces.