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
npm install @mixedrays/keyrove
yarn 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));
<ul id="menu">
<li data-keyrove-item tabindex="0">Inbox</li>
<li data-keyrove-item tabindex="0">Drafts</li>
<li data-keyrove-item tabindex="0">Sent</li>
</ul>
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>
);
import { keyRove } from '@mixedrays/keyrove';
export const Menu = ({ items, loop = false }) => (
<ul
data-keyrove-root
data-keyrove-loop={loop ? 'true' : 'false'}
onKeyDown={keyRove}
>
{items.map((item) => (
<li key={item.id} data-keyrove-item 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>
<script setup lang="ts">
import { keyRove } from '@mixedrays/keyrove';
defineProps<{ items: { id: string; label: string }[]; loop?: boolean }>();
</script>
<template>
<ul
data-keyrove-root
:data-keyrove-loop="loop ? 'true' : 'false'"
@keydown="keyRove"
>
<li v-for="item in items" :key="item.id" data-keyrove-item 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>
<script lang="ts">
import { keyRove } from '@mixedrays/keyrove';
let { items, loop = false }: {
items: { id: string; label: string }[];
loop?: boolean;
} = $props();
</script>
<ul
data-keyrove-root
data-keyrove-loop={loop ? 'true' : 'false'}
onkeydown={keyRove}
>
{#each items as item (item.id)}
<li data-keyrove-item 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>
document.querySelector('#panel').addEventListener('keydown', (e) => keyRove(e));
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>
import {
createTypeahead,
followFocus,
initRovingTabindex,
keyRove,
} from '@mixedrays/keyrove';
const list = document.querySelector<HTMLElement>('#choices')!;
const config = { items: 'button', rovingTabindex: true };
// Created once, so its buffer lasts between keypresses.
const typeahead = createTypeahead(config);
initRovingTabindex(list, config);
list.addEventListener('keydown', (e) => keyRove(e, config) || typeahead(e));
list.addEventListener('focusin', (e) => followFocus(e, config));
initRovingTabindexgives the first buttontabindex="0"and the others-1, so Tab enters the group once.keyRovemoves focus with the arrows, Home, End and the page keys, and carries the tab stop with each move.followFocusmoves the tab stop when focus arrives another way: a click,element.focus(), or Tab onto a control inside an item.typeaheadfocuses the first button whose label starts with the typed text. Without typeahead, removecreateTypeaheadfrom the import, thetypeaheadline 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.