Introduction
What keyrove does, which keys it moves focus with, how it sits beside native Tab navigation, and what it deliberately leaves to you.
keyrove makes a list or a grid keyboard-navigable. You mark the navigable
elements with a data-* attribute and forward keydown events to one function;
it works out which element should receive focus next and moves it there.
That is the whole library. It does not render anything, own any state, or wrap your components.
Two things follow from that, and they are worth saying up front because both are easy to assume the other way:
- It is not an arrow-key library. Arrows are the default binding. Which keys move focus is markup, so a group can be driven by J / K, W / S, or whatever your widget reads as forward and back.
- It does not replace Tab. keyrove moves real DOM focus and stays out of the way of every key it is not bound to, so sequential focus navigation keeps working exactly as the browser does it.
How it works
Three pieces do all the work:
- Items carry
data-keyrove-item. Everything keyrove can move focus to is found by that attribute, in DOM order. - The root is the element whose keydown you forward — or the nearest
ancestor carrying
data-keyrove-root. It scopes the item query, and its attributes configure the group. - The call is
keyRove(event). It reads the event'scode, finds the target element, focuses it, and callspreventDefault()— but only for the keys it is bound to, so the page does not scroll out from under you while every other key keeps its browser default.
import { keyRove } from '@mixedrays/keyrove';
list.addEventListener('keydown', (e) => keyRove(e));
Because the configuration lives in the markup, there is no options object to keep in sync with the DOM. A list that grows a column count becomes a grid by gaining an attribute, and nothing in your JavaScript changes.
What it handles
- Forward and back, by default ↓ and ↑ — one item, or a whole row once the group declares columns. Either can be rebound; see below.
- ← → move a single cell, in grids only.
- Home / End jump to the first and last navigable item.
- PageUp / PageDown move by
data-keyrove-page-lengthitems — whole rows in a grid. - Roving tabindex, so Tab enters and leaves a group rather than walking through every item in it.
- Skipping: items marked
data-keyrove-skip, and anythingdisabled, stay in the DOM but out of the navigation order.
Which keys move focus
The forward and back keys are configuration, not a fixed part of the library.
data-keyrove-next-key and data-keyrove-prev-key on the root take any
KeyboardEvent.code,
and default to ArrowDown and ArrowUp only because that is what most lists
want.
<!-- A toolbar reads left to right. -->
<div data-keyrove-next-key="ArrowRight" data-keyrove-prev-key="ArrowLeft">
…
</div>
<!-- A results list in an app whose users expect vim keys. -->
<ul data-keyrove-next-key="KeyJ" data-keyrove-prev-key="KeyK">
…
</ul>
Two consequences are worth holding on to. Keys you have not bound are never
touched — bind KeyJ and KeyK and the arrow keys go back to scrolling the
page. And because each root is read separately, two groups on one page can
answer to entirely different keys.
Home, End, PageUp and PageDown are fixed; they are not rebindable today. Custom keys covers the rebinding rules in full, including what happens in a grid.
Tab still works
keyrove is deliberately additive to the browser's own focus model rather than a replacement for it:
- Focus moves through
element.focus(), so focus order,:focus-visible, scroll-into-view, and what a screen reader announces are all the native behaviours. preventDefault()is called only in the branches keyrove acts on. Tab, Shift+Tab, Enter, Space and Escape reach your handlers and the browser untouched.- An item counts as focused when focus is anywhere inside it, so an item wrapping a link or a button still navigates after Tab lands on that inner control.
So the two navigation models compose. Items given tabindex="0" are ordinary
tab stops that Tab walks through and the bound keys
move between — no configuration required, and nothing to undo if you would
rather keep the plain tab order.
When a long group should not cost the page twenty tab stops, opt into roving tabindex: the group becomes a single stop that Tab moves past, and the bound keys move within it. That is the arrangement the ARIA authoring practices describe for composite widgets — but it is opt-in, one attribute at a time.
What it leaves to you
keyrove moves focus. It does not decide what focus means in your widget, so these remain yours:
- Roles and ARIA. keyrove never writes
role,aria-selected, oraria-activedescendant. A listbox needs those; what they should say depends on the widget you are building. - Selection and activation. Enter and Space are not touched, and neither is any other key you have not bound — wiring them up is one more listener on the same element.
- Tab stops on first render. keyrove moves an existing tab stop; it does not
create one. Give the first item
tabindex="0"yourself, or calltoggleTabIndex.
Framework support
There is no adapter, and none is needed. keyRove accepts anything with
code, target, currentTarget, and preventDefault — which is the shape of
a native KeyboardEvent and of every framework's synthetic wrapper around one.
The modifier flags (ctrlKey, altKey, shiftKey, metaKey) are read when
present; events carry them natively, but an object of your own that bridges
events must forward them or every press reads as unmodified.
<ul onKeyDown={(e) => keyRove(e)}>
{items.map((item) => (
<li key={item.id} data-keyrove-item tabIndex={0}>
{item.label}
</li>
))}
</ul>
See Installation for the wiring in each framework, or start with the basic list example. If you came here for the key bindings, custom keys is the page you want.