Keyboard navigation that behaves itself.
Mark the items and pass keydown to keyRove. Lists, grids and trees then
move focus the way keyboard users expect, in any framework.
Get started Examples
npm i @mixedrays/keyrove
- ~5 kB gzipped
- zero deps
- any framework
<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">Trash</li>
</ul>
import { keyRove } from '@mixedrays/keyrove';
// whole setup
const menu = document.querySelector('#menu');
menu.addEventListener('keydown', (e) => keyRove(e));
↑ ↓ move, Home and End jump. Tab still visits each folder.
01 / Your stack
Use it in your framework
The same items, the same handler. keyRove accepts native keyboard events
and compatible framework events, including React's synthetic events.
There is no adapter or wrapper component to install.
Start with ordinary attributes. When you want type-checked settings, attribute builders fit the same templates.
import { keyRove } from '@mixedrays/keyrove';
const menu = document.querySelector('#menu');
menu.addEventListener('keydown', (e) => keyRove(e));
import { keyRove } from '@mixedrays/keyrove';
export const Menu = () => (
<ul onKeyDown={keyRove}>
<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>
);
<script setup>
import { keyRove } from '@mixedrays/keyrove';
</script>
<template>
<ul @keydown="keyRove">
<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>
</template>
<script>
import { keyRove } from '@mixedrays/keyrove';
</script>
<ul onkeydown={keyRove}>
<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>
02 / Your settings
Add only the behavior you need
Keep the handler. Change a setting in the markup.
Looping wraps from the last item to the first. Horizontal changes the default arrows. One tab stop lets Tab enter and leave the group while the arrows move within it.
Each example adds just one behavior to the list you already know.
- Inbox
- Drafts
- Sent
<ul id="folders-loop" aria-label="Mail folders" data-keyrove-loop>
<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>
import { keyRove } from '@mixedrays/keyrove';
const folders = document.querySelector('#folders-loop');
folders.addEventListener('keydown', (e) => keyRove(e));
Press ↓ on Sent to return to Inbox.
- Inbox
- Drafts
- Sent
<ul
id="folders-horizontal"
aria-label="Mail folders"
data-keyrove-orientation="horizontal"
>
<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>
import { keyRove } from '@mixedrays/keyrove';
const folders = document.querySelector('#folders-horizontal');
folders.addEventListener('keydown', (e) => keyRove(e));
Use ← and → to move between folders.
- Inbox
- Drafts
- Sent
<ul id="folders-roving" aria-label="Mail folders">
<li data-keyrove-item data-keyrove-roving-tabindex tabindex="0">Inbox</li>
<li data-keyrove-item data-keyrove-roving-tabindex tabindex="-1">Drafts</li>
<li data-keyrove-item data-keyrove-roving-tabindex tabindex="-1">Sent</li>
</ul>
import { keyRove } from '@mixedrays/keyrove';
const folders = document.querySelector('#folders-roving');
folders.addEventListener('keydown', (e) => keyRove(e));
Move with the arrows, then press Tab to leave the group.
03 / Your markup
Configure it where it fits
Write attributes when you own the markup. Use an item selector when the HTML comes from a component library or CMS. These two setups navigate identically.
You can mix both: keyRove(e, { loop: true }) overrides looping while still
reading the items and other settings from their attributes.
<ul id="folders">
<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>
<script type="module">
import { keyRove } from '@mixedrays/keyrove';
const folders = document.querySelector('#folders');
folders.addEventListener('keydown', (e) => keyRove(e));
</script>
<ul id="folders">
<li tabindex="0">Inbox</li>
<li tabindex="0">Drafts</li>
<li tabindex="0">Sent</li>
</ul>
<script type="module">
import { keyRove } from '@mixedrays/keyrove';
const folders = document.querySelector('#folders');
folders.addEventListener('keydown', (e) => keyRove(e, { items: 'li' }));
</script>
04 / Your layout
Move through a grid
Give the group a column count and the same handler moves across cells and between rows. Your CSS still controls the layout.
For a responsive CSS grid, set data-keyrove-cols="auto". keyrove reads the
current tracks on each keypress, so resizing needs no navigation instance
to update.
<div
id="slots"
data-keyrove-cols="3"
style="display: grid; grid-template-columns: repeat(3, 1fr)"
>
<button data-keyrove-item>09:00</button>
<button data-keyrove-item>09:30</button>
<button data-keyrove-item>10:00</button>
<!-- … -->
<button data-keyrove-item>13:00</button>
</div>
import { keyRove } from '@mixedrays/keyrove';
const slots = document.querySelector('#slots');
slots.addEventListener('keydown', (e) => keyRove(e));
Use all four arrows. Home and End jump to the ends of a row.
<div id="month-picker">
<style>
#month-picker {
container-type: inline-size;
}
#months {
--cols: 2;
display: grid;
grid-template-columns: repeat(var(--cols), minmax(0, 1fr));
gap: 0.375rem;
}
@container (min-width: 20rem) {
#months {
--cols: 3;
}
}
@container (min-width: 28rem) {
#months {
--cols: 4;
}
}
@container (min-width: 36rem) {
#months {
--cols: 6;
}
}
</style>
<div id="months" data-keyrove-root data-keyrove-cols="auto">
<button data-keyrove-item tabindex="0">January</button>
<button data-keyrove-item tabindex="0">February</button>
<button data-keyrove-item tabindex="0">March</button>
<!-- … -->
<button data-keyrove-item tabindex="0">December</button>
</div>
</div>
import { keyRove } from '@mixedrays/keyrove';
const months = document.querySelector('#months');
months.addEventListener('keydown', (e) => keyRove(e));
Drag the preview’s lower-right corner to resize it, then try the arrows.
05 / Your interaction
Combine behaviors
Now put the pieces together: a menu configured with an item selector, looping, and one tab stop.
Add createTypeahead when people should also be able to find an item by
its label. Both handlers share the same group settings; || passes a key
to typeahead only when navigation leaves it unhandled.
<div id="share" role="menu" aria-label="Share">
<button type="button" role="menuitem" tabindex="0">Copy link</button>
<button type="button" role="menuitem" tabindex="-1">Email a copy</button>
<button type="button" role="menuitem" tabindex="-1" disabled>
Export as PDF
</button>
<button type="button" role="menuitem" tabindex="-1">Post to Slack</button>
<button type="button" role="menuitem" tabindex="-1">Open in Figma</button>
</div>
import { createTypeahead, keyRove } from '@mixedrays/keyrove';
const config = {
items: '[role="menuitem"]',
loop: true,
rovingTabindex: true,
};
const typeahead = createTypeahead(config);
const menu = document.querySelector('#share');
menu.addEventListener('keydown', (e) => keyRove(e, config) || typeahead(e));
Type E to focus Email a copy. Arrows skip the disabled item and wrap at the ends.
06 / Your application
Build more involved interfaces
Give an item a shortcut. Nest groups with their own keys. Or combine focus navigation with your application's behavior.
The tree is the fullest example: keyrove navigates visible rows and typeahead finds them by name. Your code opens and closes folders, sets ARIA states, and decides what activating a file does.
Start with the part you need; the same handler stays underneath.
<div
id="tools"
role="toolbar"
aria-orientation="vertical"
aria-label="Drawing tools"
>
<button
data-keyrove-item
data-keyrove-roving-tabindex
data-keyrove-focus-key="KeyV"
aria-keyshortcuts="V"
tabindex="0"
>
Move
</button>
<button
data-keyrove-item
data-keyrove-roving-tabindex
data-keyrove-focus-key="KeyR"
aria-keyshortcuts="R"
tabindex="-1"
>
Rectangle
</button>
<!-- … -->
<button
data-keyrove-item
data-keyrove-roving-tabindex
data-keyrove-focus-key="KeyT"
aria-keyshortcuts="T"
tabindex="-1"
>
Text
</button>
</div>
import { keyRove } from '@mixedrays/keyrove';
const tools = document.querySelector('#tools');
tools.addEventListener('keydown', (e) => keyRove(e));
Press P for Pen, then ↓ to continue.
- Reply
- Reply in thread
- Copy link
- Pin to channel
- Delete message
<ul id="message-actions">
<li
data-keyrove-root
data-keyrove-next-key="ArrowRight"
data-keyrove-prev-key="ArrowLeft"
data-keyrove-exit-key="Escape"
>
<button data-keyrove-item tabindex="0">👍</button>
<button data-keyrove-item tabindex="0">❤️</button>
<!-- … -->
<button data-keyrove-item tabindex="0">🎉</button>
</li>
<li data-keyrove-item tabindex="0">Reply</li>
<li data-keyrove-item tabindex="0">Reply in thread</li>
<li data-keyrove-item tabindex="0">Copy link</li>
<!-- … -->
<li data-keyrove-item tabindex="0">Delete message</li>
</ul>
import { keyRove } from '@mixedrays/keyrove';
const actions = document.querySelector('#message-actions');
// One listener also serves the nested reaction row.
actions.addEventListener('keydown', (e) => keyRove(e));
Use ↑/↓ in the menu. Focus a reaction to try ←/→; Esc leaves its group.
-
src
-
components
- Menu.tsx
- Tabs.tsx
- Tree.tsx
-
utils
- dom.ts
- keys.ts
- main.ts
- styles.css
-
-
node_modules
- left-pad
-
public
- favicon.svg
- robots.txt
- package.json
- README.md
- tsconfig.json
<ul id="files" role="tree" aria-label="Project files">
<li role="none">
<div
role="treeitem"
aria-expanded="true"
aria-owns="files-src"
tabindex="0"
>
src
</div>
<ul id="files-src" role="group">
<li role="none">
<div
role="treeitem"
aria-expanded="false"
aria-owns="files-components"
tabindex="-1"
>
components
</div>
<ul id="files-components" role="group" hidden>
<li role="none"><div role="treeitem" tabindex="-1">Menu.tsx</div></li>
<!-- … -->
<li role="none"><div role="treeitem" tabindex="-1">Tree.tsx</div></li>
</ul>
</li>
<li role="none">
<div
role="treeitem"
aria-expanded="false"
aria-owns="files-utils"
tabindex="-1"
>
utils
</div>
<ul id="files-utils" role="group" hidden>
<li role="none"><div role="treeitem" tabindex="-1">dom.ts</div></li>
<li role="none"><div role="treeitem" tabindex="-1">keys.ts</div></li>
</ul>
</li>
<li role="none"><div role="treeitem" tabindex="-1">main.ts</div></li>
<!-- … -->
</ul>
</li>
<!-- … -->
<li role="none"><div role="treeitem" tabindex="-1">package.json</div></li>
<li role="none"><div role="treeitem" tabindex="-1">README.md</div></li>
<li role="none"><div role="treeitem" tabindex="-1">tsconfig.json</div></li>
</ul>
import { createTypeahead, keyRove } from '@mixedrays/keyrove';
const tree = document.querySelector('#files');
const config = {
items: '[role="treeitem"]',
skip: '[hidden] [role="treeitem"]',
rovingTabindex: true,
};
const typeahead = createTypeahead(config);
// branch is your application's expand/collapse handler.
// The full tree example includes its implementation.
tree.addEventListener(
'keydown',
(e) => keyRove(e, config) || typeahead(e) || branch(e),
);
Use → to open a folder and ← to close it. Type a file name to jump.
What it leaves to you
keyrove moves focus and nothing else, so it fits whatever widget you are building.
keyrove does
- Finds the next item and focuses it
- Prevents the browser's default only for keys it handles
- Moves the tab stop with focus when roving tabindex is on
You do
- Roles, labels and ARIA states
- Selection, and what Enter and Space do
- Focusable items and the first tab stop
The listbox example puts all three together.
Start with a list
Install the package, mark the items, and pass keydown to keyRove.
Get started
npm i @mixedrays/keyrove