npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@mixedrays/keyrove

v2.4.0

Published

Framework-agnostic keyboard navigation for lists and grids, driven by data-* attributes. Any key can move focus; arrows are the default.

Readme

@mixedrays/keyrove

npm minzipped size license

Framework-agnostic keyboard navigation for lists and grids, driven by data-* attributes.

Documentation · API reference · Examples

Arrow keys are the default binding, not the whole library: the keys that move focus are data-* attributes on the root, so any KeyboardEvent.code can drive a group. And because keyrove moves real DOM focus and only calls preventDefault() on the keys it is bound to, native Tab / Shift+Tab navigation keeps working alongside it.

pnpm add @mixedrays/keyrove

Features

  • Framework-agnostic: takes DOM events and React, Vue, or Svelte synthetic events, with no adapter and no dependencies.
  • Configurable key bindings: every move — next/prev, the grid's row moves, Home/End and the page jumps — has a data-keyrove-*-key attribute taking any KeyboardEvent.code, with exact modifier combos and platform-aware mod.
  • Focus keys: data-keyrove-focus-key gives an element a combo of its own — ctrl+shift+KeyE, or just KeyE — that focuses it from anywhere under the listener: another group, a nested root, even a text field when the combo holds a modifier. It need not be an item, so a panel reached by its key stays out of the arrow order.
  • Lists and grids: arrows, Home/End and PageUp/PageDown out of the box; data-keyrove-cols folds the items into rows — Up/Down move a whole row, Left/Right move a cell — and data-keyrove-loop wraps a list at its ends.
  • Horizontal and RTL groups: data-keyrove-orientation="horizontal" re-points a list's defaults at / — and a grid's default cell arrows follow the reading direction too, flipped under RTL from the nearest dir.
  • Native focus behavior: moves real DOM focus and calls preventDefault() only on the keys it is bound to, so unbound keys and Tab/Shift+Tab are left untouched.
  • Roving tabindex: data-keyrove-roving-tabindex moves the tabindex="0" tab stop with focus, so Tab enters and leaves a group instead of walking through every item in it.
  • Skippable items: data-keyrove-skip and disabled keep headings, separators, and dead entries in the DOM but out of the navigation order.
  • Nested roots: data-keyrove-root scopes a group and the nearest one wins, so a single delegated listener can serve a list inside a list.
  • Editable control awareness: the caret and value keys stay with inputs, textareas, selects, and contenteditable regions — while inputs those keys do nothing on, like a checkbox or a button, keep navigating.
  • Typeahead: createTypeahead() adds case-insensitive type-to-focus, matching data-keyrove-typeahead or the item's own text.
  • Markup you don't own: every attribute has an option of the same name, so a group can be described in JavaScript instead — keyRove(e, { items: '[role="menuitem"]', loop: true }) navigates a component library's menu that carries no keyrove attributes at all. Each field falls back to its attribute on its own, so the two can be mixed.

Usage

Mark navigable elements with data-keyrove-item, give them a tab stop, and pass the container's keydown event to keyRove.

<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';

document.querySelector('#menu').addEventListener('keydown', (e) => keyRove(e));

keyRove accepts anything shaped like a keydown event, so React, Vue and Svelte synthetic events work without an adapter:

<ul onKeyDown={(e) => keyRove(e)}>
  {items.map((item) => (
    <li key={item.id} data-keyrove-item tabIndex={0}>
      {item.label}
    </li>
  ))}
</ul>

Keys

ArrowDown and ArrowUp move forward and back by default. Rebind either on the root — KeyJ/KeyK, KeyW/KeyS, ArrowRight/ArrowLeft for a toolbar:

<div data-keyrove-next-key="KeyJ" data-keyrove-prev-key="KeyK">…</div>

For the toolbar case there is a shorthand that also respects the text direction: data-keyrove-orientation="horizontal" maps the default keys to ArrowRight/ArrowLeft, flipped under RTL. An explicit data-keyrove-next-key/data-keyrove-prev-key still wins over it.

A binding is a combo: zero or more of mod+ / ctrl+ / alt+ / shift+ / meta+ (any order, any case) followed by a KeyboardEvent.code. mod resolves to meta on Apple platforms and ctrl elsewhere, and the longer control, option, cmd and command spell the same modifiers. Matching is exact — declared modifiers are required, undeclared ones are forbidden — so a bare ArrowDown binding leaves shortcuts like Ctrl+ArrowDown with their browser defaults. Keys are matched on e.code, the physical key, so bindings hold across keyboard layouts. The matcher is exported as matchesCombo(e, combo) for your own handlers.

<div
  data-keyrove-next-key="ctrl+ArrowRight"
  data-keyrove-prev-key="ctrl+ArrowLeft"
>
  …
</div>

Next and prev always mean one item through the DOM order. Declare data-keyrove-cols and the same items fold into rows: next-key/prev-key keep moving one item — a cell there, on the reading-direction arrows by default — while data-keyrove-next-row-key/data-keyrove-prev-row-key move a whole row, defaulting to ArrowDown/ArrowUp.

Anything not bound is left entirely alone, browser defaults included. Home, End, PageUp and PageDown are defaults like the arrows — data-keyrove-home-key, data-keyrove-end-key, data-keyrove-page-up-key and data-keyrove-page-down-key rebind them — and whatever they are bound to they act only once focus is already inside an item: they move within a group, never into one. In a grid, bare Home/End jump to the ends of the focused row (data-keyrove-home-row-key/data-keyrove-end-row-key) and ctrl+Home/ctrl+End to the grid's first and last cell.

At the ends of a list the bound keys are consumed but focus stays put. Add data-keyrove-loop on the root and next on the last item wraps to the first, and vice versa. Grids keep their edges — they never wrap.

Keys pressed inside an editable element — textarea, select, [contenteditable], or an input whose keys act natively (text entry, number, range, radio, …) — are never handled: arrows and Home/End keep moving the caret or value, and a letter binding like KeyJ does not swallow typing into a field that sits within an item. Inputs where those keys are inert — a checkbox, a button — still navigate.

Focus keys

Every move above is relative to where focus is. data-keyrove-focus-key is the absolute kind: the combo focuses its element from anywhere the keydown reaches the listener — a sibling group, a nested root, or, when the combo holds Ctrl/Alt/Meta, a text field.

<div id="panels">
  <section
    data-keyrove-root
    data-keyrove-focus-key="ctrl+shift+KeyE"
    tabindex="-1"
  >
    …
  </section>
  <section
    data-keyrove-root
    data-keyrove-focus-key="ctrl+shift+KeyB"
    tabindex="-1"
  >
    …
  </section>
</div>

The element need not be an item. An item stays in its group's arrow order, and the jump carries the roving tab stop like any move. Any other element — the panels above — is reached by its key alone, from outside any group: from is null and no tab stop moves. Each panel is a root, so an arrow pressed on it enters its own items rather than the first item under the listener.

The listener's placement is the reach — on document, the keys are page-wide. A focus key sits ahead of the root's bindings and the defaults, so it wins any collision; two elements naming one combo resolve to the first in DOM order; a skipped or disabled element's key is inert. The move reports 'focus'.

Tab still works

keyrove moves focus with element.focus() and never touches Tab, so sequential focus navigation is unaffected. Items with tabindex="0" stay ordinary tab stops that arrows also reach. Opt into data-keyrove-roving-tabindex when a group should instead be a single tab stop that Tab moves past rather than through.

Typeahead

createTypeahead adds type-to-focus: printable characters accumulate in a buffer (reset after 500 ms of silence), and focus jumps to the first item whose label starts with what was typed, case-insensitively.

import { keyRove, createTypeahead } from '@mixedrays/keyrove';

const typeahead = createTypeahead(); // { resetMs?, matchMode?, onMove? }

list.addEventListener('keydown', (e) => keyRove(e) || typeahead(e));

The buffer is state, which keyRove itself never holds, so create one handler per listener and chain it after keyRove: bound keys win, and a KeyJ binding keeps navigating instead of entering the buffer. The label is the item's data-keyrove-typeahead attribute, falling back to its trimmed text. Matching reads e.key — the typed character — unlike key bindings, which stay on the physical e.code. Typing inside editable elements is never captured, modified presses (Ctrl/Alt/Meta) are left to their shortcuts, and a space only counts once a match is underway. The handler returns { action: 'typeahead', from, to } or null, the same contract as keyRove, and its onMove fires after a real move exactly as keyRove's does, so both handlers can feed the same follow-focus logic.

Repeated characters normally extend the prefix: S, then S, looks for an item starting with ss. For menus that cycle through same-letter items, pass { matchMode: 'cycle' }: a single character moves to the next item after the focused one that starts with it, wrapping, so repeated S presses step through the S items at any pace. A different character typed before the buffer resets still refines the prefix.

Attributes

| Attribute | On | Default | Meaning | | ------------------------------ | ---- | ----------- | -------------------------------------------------------------------------------------- | | data-keyrove-item | item | — | Marks an element as navigable. | | data-keyrove-skip | item | — | Passed over when moving; stays in the DOM order. | | data-keyrove-roving-tabindex | item | — | Moves the tabindex="0" tab stop with focus. | | data-keyrove-root | root | — | Marks the navigation root explicitly, instead of using the listener's element. | | data-keyrove-cols | root | 1 | Column count; above 1 the group navigates as a grid. | | data-keyrove-page-length | root | 10 | Items per page jump — whole rows in a grid. | | data-keyrove-next-key | root | axis arrow | Combo for the next item — the next cell, in a grid. E.g. KeyJ or ctrl+ArrowRight. | | data-keyrove-prev-key | root | axis arrow | Combo for the previous item. | | data-keyrove-next-row-key | root | ArrowDown | Combo for the next row, same column. Grids only. | | data-keyrove-prev-row-key | root | ArrowUp | Combo for the previous row. Grids only. | | data-keyrove-home-key | root | Home | Combo for the first item — the grid's first cell, ctrl+Home there by default. | | data-keyrove-end-key | root | End | Combo for the last item — the grid's last cell, ctrl+End there by default. | | data-keyrove-home-row-key | root | Home | Combo for the focused row's first cell. Grids only. | | data-keyrove-end-row-key | root | End | Combo for the focused row's last cell. Grids only. | | data-keyrove-page-up-key | root | PageUp | Combo for the page jump back. | | data-keyrove-page-down-key | root | PageDown | Combo for the page jump forward. | | data-keyrove-focus-key | any | — | Combo focusing this element, from anywhere under the listener, e.g. ctrl+shift+KeyE. | | data-keyrove-loop | root | — | Next/prev wrap past the ends of a list. Grids never wrap. | | data-keyrove-orientation | root | — | horizontal maps a list's default keys to ArrowRight/ArrowLeft, RTL-aware. | | data-keyrove-typeahead | item | text | Label for type-to-focus, when the item's own text is not it. |

The boolean attributes — data-keyrove-item, data-keyrove-skip, data-keyrove-roving-tabindex, data-keyrove-root, and data-keyrove-loop — are enabled when bare or set to "true"; set one to "false" to disable it.

The next/prev defaults follow the group's axis: ArrowDown/ArrowUp in a vertical list, the reading-direction arrows in a horizontal list or a grid.

Every attribute name is also exported as a constant (KEYROVE_ATTR_ITEM, KEYROVE_ATTR_COLS, KEYROVE_ATTR_NEXT_ROW_KEY, KEYROVE_ATTR_LOOP, …).

Options and return value

const result = keyRove(e, {
  onMove: ({ action, from, to }) => {},
});

Beside onMove, the options object takes the group's settings themselves — items, root, cols, loop, orientation, pageLength, keys, focusKeys, skip and rovingTabindex — each named after the attribute it stands for and each falling back to that attribute on its own. So a group can be described in markup, in JavaScript, or in any mixture:

keyRove(e); // everything from the markup
keyRove(e, { loop: true }); // items from the markup, looping from here
keyRove(e, { items: '[role="menuitem"]', loop: true }); // nothing from the markup

items takes a selector run inside the root, or (root) => Element[]; skip a selector or a test of your own; keys the combo per move, read move by move, so { keys: { next: 'KeyJ' } } leaves the others to their attributes and defaults. Options are read on every keypress, so an object built at the call site is as live as an attribute is. createTypeahead takes the settings it needs — items, root, skip, rovingTabindex, plus a label of its own — under the same names, so one object configures both handlers.

onMove fires after focus has moved, and only when it actually moved: a consumed key with nowhere to go — the end of a list, the edge of a grid — fires nothing. action names the move: 'next' | 'prev' | 'home' | 'end' | 'pageUp' | 'pageDown', the grid-only 'nextRow' | 'prevRow' | 'homeRow' | 'endRow', and 'focus' for a focus key. from is the item focus left (null when the group was entered from outside, or for a focus key on an element that is not an item) and to the element it landed on.

keyRove returns null when it left the key untouched, and { action, from, to } when it consumed it — with to: null for a consumed no-op at an edge. A non-null result means the key is claimed, so handlers chain with ||:

element.addEventListener('keydown', (e) => keyRove(e) || myOwnHandler(e));

toggleTabIndex({ root, isActive }) is exported for cases where you manage the tab stop yourself — restoring it after re-rendering a list, for instance.

License

MIT