Introduction
Add keyboard navigation to lists, grids and trees with one keydown handler, then choose keys and options while native Tab behavior keeps working.
keyrove moves focus through lists, grids and trees. Mark the items you want to
navigate and pass keydown events to keyRove. It finds and focuses the next
item without rendering UI or wrapping your components.
<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>
import { keyRove } from '@mixedrays/keyrove';
const menu = document.querySelector<HTMLElement>('#menu')!;
menu.addEventListener('keydown', (e) => keyRove(e));
// Keep <li tabindex="0">, but select items without data-keyrove-item.
import { keyRove } from '@mixedrays/keyrove';
const menu = document.querySelector<HTMLElement>('#menu')!;
menu.addEventListener('keydown', (e) => keyRove(e, { items: 'li' }));
This list supports arrows, Home, End and page jumps. Tab still visits each item. You can change the keys and configure the group with attributes, options, or both.
How it works
- Items are the elements marked with
data-keyrove-item, read in DOM order. Anitemsoption can select them instead. - The root is the nearest element at or above the event target marked with
data-keyrove-root, or the listener's element if none is marked. It contains the items and carries the group's settings. - The handler,
keyRove(event), reads the key, finds the target item and focuses it. It callspreventDefault()for handled keys, so navigation does not also scroll the page. Unhandled keys keep their browser defaults.
The handler reads the current DOM and settings on every keypress. Changing items or adding a column count requires no navigation instance to update.
What it handles
| Feature | Behavior | Example |
|---|---|---|
| Lists | ↑/↓ move one item in DOM order | Basic list |
| Grids | ↑/↓ move a row; ←/→ move a cell | Grid |
| First and last items | Home/End jump to list ends or grid row ends; Ctrl+Home/End jump to grid ends | Grid |
| Page jumps | PageUp/PageDown move 10 items or rows by default | Page length |
| Horizontal lists | ←/→ follow the text direction | Horizontal lists |
| Looping | Next/previous wrap at list ends | Looping lists |
| Roving tabindex | One tab stop follows focus within a group | Roving tabindex |
| Skipped items | Pass over items marked with data-keyrove-skip or disabled | Skipped items |
| Nested groups | Each group has its own keys, with optional enter and exit bindings | Nested roots |
| Focus shortcuts | Focus an item or panel from anywhere under the listener | Focus keys |
| Typeahead | Add createTypeahead() to focus items by typing their labels | Typeahead |
| Trees | Navigate visible rows; your handlers expand and collapse branches | Tree view |
| Moves from code | Call rove(list, 'next') from a button, gamepad or remote | rove |
Text fields, selects and editable content keep their editing keys. See editable targets for the rules and exceptions.
Where a group is described
Use data-keyrove-* attributes in your markup or pass options to the handler.
Options are useful when the HTML comes from a component library or CMS:
import { keyRove } from '@mixedrays/keyrove';
const config = { items: '[role="menuitem"]', loop: true };
const menu = document.querySelector<HTMLElement>('#share')!;
menu.addEventListener('keydown', (e) => keyRove(e, config));
Options override attributes one setting at a time. For example,
keyRove(e, { loop: true }) enables looping while reading items and key bindings
from the markup.
See attributes and options for the full mapping, or options in JavaScript for a complete demo.
Which keys move focus
Lists use ArrowDown for next and ArrowUp for previous by default. Set
data-keyrove-next-key and data-keyrove-prev-key on the root to use other
KeyboardEvent.code values or combinations such as mod+KeyJ:
<ul data-keyrove-next-key="KeyJ" data-keyrove-prev-key="KeyK">
…
</ul>
Here, J and K move focus, and the arrows return to their browser defaults. Each group can have different bindings. Home, End, PageUp and PageDown can also be rebound with their own key attributes.
See custom keys for examples and the API reference for defaults, syntax and precedence.
Tab still works
keyrove uses element.focus(), preserving the browser's focus styling,
scrolling and focus announcements. An item also counts as focused when a link,
button or other element inside it has focus.
With the default bindings, Tab, Shift+Tab, Enter, Space and Escape keep their
usual behavior. Items with tabindex="0" remain ordinary tab stops, reachable
with both Tab and the bound navigation keys.
For a single tab stop per group, use roving tabindex. Tab enters and leaves the group, while the bound keys move between its items.
What it leaves to you
- Roles and ARIA. Set the roles, labels and states your widget needs.
keyrove does not write
role,aria-selectedoraria-activedescendant. - Selection and activation. Decide what clicks, Enter and Space do, and add your own handlers. They are unbound by default.
- Initial tab stops. Make items focusable in your markup. For a roving
group, give one item
tabindex="0"and the others-1, or callinitRovingTabindexafter rendering.
The listbox example combines navigation with all three.
Framework support
keyRove accepts native keyboard events and compatible framework events,
including React's synthetic events. See
KeyRoveEvent for the required shape.
Installation shows setup in vanilla JavaScript, React, Vue and Svelte. Start with the basic list to try it.