Installation
Installing the package, wiring the first keydown handler in any framework, and what the markup has to carry.
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>
The tabindex="0" is yours to put there — keyrove moves focus but never makes
an element focusable. It is also what keeps these items reachable by
Tab, which keyrove leaves alone; if you would rather the
group were a single tab stop, that is
roving tabindex.
By default the group answers to ↑ and
↓. Add data-keyrove-next-key / data-keyrove-prev-key
to the root for anything else — see custom keys.
React
import { keyRove } from '@mixedrays/keyrove';
export const Menu = ({ items }) => (
<ul 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 { keyRove } from '@mixedrays/keyrove';
</script>
<template>
<ul @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 { keyRove } from '@mixedrays/keyrove';
</script>
<ul onkeydown={keyRove}>
{#each items as item (item.id)}
<li data-keyrove-item tabindex="0">{item.label}</li>
{/each}
</ul>
Several groups, one listener
The navigation root is the nearest ancestor carrying data-keyrove-root,
falling back to the element the listener is attached to. Marking each group as a
root lets a single delegated listener — on a panel, or on document — serve any
number of independent groups without them seeing each other's items.
<div id="panel">
<ul data-keyrove-root>
<li data-keyrove-item tabindex="0">Inbox</li>
<li data-keyrove-item tabindex="0">Drafts</li>
</ul>
<ul data-keyrove-root data-keyrove-cols-length="4">
<li data-keyrove-item tabindex="0">1</li>
<li data-keyrove-item tabindex="0">2</li>
</ul>
</div>
document.querySelector('#panel').addEventListener('keydown', (e) => keyRove(e));
Roots can also sit inside one another, which is how a group that is part of another group's flow keeps its own keys and columns — see nested roots.
Attribute constants
Every attribute name is exported as a constant, so markup built in JavaScript does not have to hardcode strings.
import { KEYROVE_ATTR_ITEM, KEYROVE_ATTR_SKIP } from '@mixedrays/keyrove';
const item = document.createElement('li');
item.setAttribute(KEYROVE_ATTR_ITEM, '');
item.tabIndex = 0;
The full list is in the API reference.