API reference

Reference for keyRove, rove, the typeahead and roving tabindex helpers, key bindings, focus behavior, data attributes and every exported TypeScript type.

ExportPurpose
keyRove(event, options?)Handles a keydown and reports the focus move.
optionsConfigures the group in JavaScript.
rove(element, action, options?)Moves focus by action name, without a keypress.
createTypeahead(options?)Creates a type-to-focus handler.
matchesCombo(event, combo)Tests a key combination in your own handlers.
initRovingTabindex(root, options?)Initializes or repairs a group's roving tab stop.
followFocus(event, options?)Updates the roving tab stop on focusin.
toggleTabIndex({ root, isActive })Sets one element's tabindex to 0 or -1.
data-keyrove-*Configures items and roots in markup.
KEYROVE_ATTR_*Constants for attribute names.
TypesEvent, configuration and result types.

keyRove(event, options?)

Handles one keydown: reads the event's code, finds the item the key resolves to within the event's navigation root, focuses it, and calls preventDefault().

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

list.addEventListener('keydown', (e) => keyRove(e));
ParameterTypeRequiredDescription
eventKeyRoveEventYesThe keydown. Native and framework events both qualify.
optionsKeyRoveOptionsNoGroup settings and onMove; see options.

Returns a MoveResult, or null when the key is not keyrove's; see return value.

Keys

Items form a sequence in DOM order. next and prev move one item. In a grid, cols divides that sequence into rows, and next-row/prev-row move one row in the same column. Each move has a *-key attribute on the root.

In a list:

Default keyBound byMoves
ArrowDowndata-keyrove-next-keyNext item
ArrowUpdata-keyrove-prev-keyPrevious item
Homedata-keyrove-home-keyFirst navigable item
Enddata-keyrove-end-keyLast navigable item
PageDowndata-keyrove-page-down-keyForward page-length items
PageUpdata-keyrove-page-up-keyBack page-length items

In a grid (data-keyrove-cols above 1; defaults shown for LTR, the cell arrows follow the reading direction):

Default keyBound byMoves
ArrowRightdata-keyrove-next-keyNext cell, flowing across row ends
ArrowLeftdata-keyrove-prev-keyPrevious cell
ArrowDowndata-keyrove-next-row-keyNext row, same column
ArrowUpdata-keyrove-prev-row-keyPrevious row, same column
Homedata-keyrove-home-row-keyFirst navigable cell of the row
Enddata-keyrove-end-row-keyLast navigable cell of the row
ctrl+Homedata-keyrove-home-keyFirst navigable cell of the grid
ctrl+Enddata-keyrove-end-keyLast navigable cell of the grid
PageDowndata-keyrove-page-down-keyForward page-length rows
PageUpdata-keyrove-page-up-keyBack page-length rows

Set a move's key attribute to replace its default binding. Set it to none to disable the binding, for example data-keyrove-page-down-key="none". The replaced key returns to the browser unless another binding handles it. See custom keys.

Exit and enter

Bind exit on an inner root to leave it, or enter on the outer root to focus a group inside the current item. Neither action has a default key.

Bound bykeys fieldMoves
data-keyrove-exit-keyexitFrom inside a nested root to the group around it
data-keyrove-enter-keyenterFrom the focused item into the root nested inside it
<ul data-keyrove-enter-key="Enter">
  <li data-keyrove-item tabindex="0">
    Build failed
    <div data-keyrove-root data-keyrove-exit-key="Escape">
      <button data-keyrove-item tabindex="0">Retry</button>
      <button data-keyrove-item tabindex="0">View logs</button>
    </div>
  </li>
</ul>
  • Exit focuses the containing item in the outer group. If none is eligible, it chooses the nearest eligible item after the root, then the nearest before it. It never exits beyond the listener's element.
  • Enter uses the first nested root inside the focused item. It chooses that group's first navigable roving item with tabindex="0", or its first navigable item. It does not try later roots if the first has no destination.
  • Both exclude skipped and disabled items and items belonging to deeper roots.
  • Each group keeps its own roving tab stop. A move updates an existing stop in the destination group, leaving the source group's stop in place. This lets enter return to the item last used in the inner group. Initialize each group's stop with roving tabindex.

When no destination is found, the key remains unhandled. This includes exit from the listener's own group and enter on an item with no nested root. If a chosen target cannot receive focus, the key is consumed with to: null and onMove does not run, as with other focus moves.

The reported actions are 'exit' and 'enter'. See nested roots for the demo.

Combos

Every *-key value is a combo, or a list of them, matched by matchesCombo:

  • Zero or more of mod+, ctrl+, alt+, shift+, meta+, in any order and any case, followed by a KeyboardEvent.code. Whitespace around the parts is ignored.
  • mod is meta on Apple platforms and ctrl elsewhere.
  • control, option, cmd and command are accepted as the longer spellings of ctrl, alt and meta.
  • The code is the physical key, matched case-sensitively and unaffected by keyboard layout: KeyJ is the same key on QWERTY and AZERTY. The plus key itself is Equal or NumpadAdd, so + is always the separator.
  • Matching is exact: every declared modifier must be held, and every undeclared one must not be. A bare KeyJ matches only while no modifier is held, so Ctrl+J keeps its browser default. A ctrl+KeyJ binding never fires on a plain J.
  • A comma separates the combos of a list, and a move answers to any of them: data-keyrove-next-key="ArrowDown, KeyJ" keeps the arrow and adds J. No code contains a comma (the comma key is Comma), and whitespace around the commas is ignored. Each entry is matched on its own, so an empty entry or one naming an unknown modifier matches nothing and leaves the others working. A list is literal like any explicit binding: to keep the default key, name it.
  • The defaults are exact combos too. A modified PageDown is left alone, and so are Ctrl+Home and Ctrl+End in a list, which has no grid-wide scope for them.
  • A combo naming an unknown modifier, or ending in a dangling +, matches nothing, not even a keydown with an empty code. An empty or blank value, or one of nothing but commas, is unset: an attribute leaves the move its default key, and a keys value leaves it to the attribute.
  • none, trimmed and in any case, is not a combo. On its own it binds the move to no key; inside a list it is an entry that matches nothing. On a focus key it is unset, since an element has no default key to free.

Precedence

One keypress resolves to at most one action. The binding table is searched in order, and the first match wins:

  1. An element's own focus key.
  2. The root's explicit *-key bindings.
  3. The defaults of the moves left unbound.

An explicit binding wins over another action's default. For example, data-keyrove-next-key="Home" makes Home move to the next item instead of the first. Replaced defaults are not restored elsewhere. none removes a move's binding. Bindings for actions the layout does not support, such as row moves in a list, are ignored.

Roots

The root is the nearest ancestor of the event target carrying data-keyrove-root, the target itself included, falling back to the element the listener is attached to (currentTarget). A listener on document or window falls back to <html>.

The search doesn't stop at the listener's element. If no root sits between the target and the listener, a root above the listener wins, and its items become the group, including any outside the listener. Settings can therefore sit on a wrapper root while the listener is on an inner element. Moves between groups stay inside the listener's element: exit, focus keys and followFocus look for the group around a nested root or an item no further out than the listener's element.

A listener on a shadow root falls back to the shadow root itself. It has no attributes, so configure that group with options, or wrap the items in an element carrying data-keyrove-root. Reading direction and cols: 'auto' are read from the host element.

  • The root's attributes configure the group. They are read on every keypress, so changing one takes effect at once; see responsive grid.
  • Items are all enabled data-keyrove-item elements under the root, in DOM order, except those carrying disabled. This includes items in nested roots. Use sibling roots to keep attribute-selected sequences separate. With roving tabindex, each root keeps its own tab stop: a move onto a nested root's item moves that root's stop and leaves the outer group's in place.
  • One delegated listener can serve several roots. Inside a nested root, only that root's movement bindings apply; unbound keys do not fall through to the outer group. Focus keys can cross root boundaries, and exit and enter move between a nested root and the group around it.
  • An item counts as focused when focus is anywhere inside it (:focus-within), so an item wrapping a link or a button is still the position after Tab lands on that inner control.

Consumed and untouched keys

preventDefault() is called for every key keyrove acts on, so the page does not scroll while you move through a list. What happens to a bound key depends on where focus is:

  • Focus inside an item of the group. The key is consumed and focus moves. At the end of a list or the edge of a grid there is nowhere to go, and the key is still consumed: a group owns its bound keys up to its own boundary.
  • Nothing in the group focused. Only the moves that can enter a group are consumed: the four directional moves, which land on the first navigable item (the last, for prev on a looping list), and a focus key, which lands on its element. Home, End, the row ends and the page moves act only once focus is inside an item; pressed here, they keep their browser default.
  • A group with no items. Movement keys keep their browser defaults. Focus shortcuts can still reach elements that are not items.
  • Exit and enter. Unhandled when no destination is found; consumed when a target is chosen, even if it cannot receive focus.

Give targets native focusability or a tabindex. If a target cannot take focus, such as a hidden or inert element, the key is consumed without moving. The roving stop is restored and onMove does not fire.

Unbound keys keep their browser behavior and remain available to your handlers. Tab, Shift+Tab, Enter, Space and Escape are unbound by default. The return value tells your handler whether keyrove consumed the key. A key another handler already consumed is left alone.

Edges and looping

At the ends of a list, next and prev are consumed without moving. Add data-keyrove-loop to the root and they wrap instead: forward from the last navigable item lands on the first, and back from the first lands on the last. Entering a looping list with prev focuses its last navigable item. Only next/prev in lists loop; grids keep their boundaries. See looping lists.

A page jump travels as far as it can. One that would overshoot lands on the last (or first) navigable item rather than doing nothing; in a grid that is the grid's last (or first) navigable cell, whatever column the jump started in. The row ends are stricter: Home and End in a grid look only at the focused row, so a row of nothing but skipped cells is a consumed no-op.

A skipped item is never a move's destination, including when every item is skipped. Then no move has anywhere to land. From outside the group, the directional keys are unhandled and keep their browser default. With focus already inside an item, such as a skipped item the user clicked, a bound move is a consumed no-op. Typeahead and roving initialization exclude skipped items the same way.

Editable targets

Movement bindings do not run inside editable elements. Those elements keep their caret, value and typing keys. Editable targets include:

  • a textarea or a select;
  • a [contenteditable] element and everything inside it, except a contenteditable="false" island, which opts back out;
  • an input of any type other than button, checkbox, color, file, image, reset and submit. On those the bound keys do nothing natively, so navigating from them takes nothing away: a list of checkbox rows keeps its arrows.

A focus key with Ctrl, Alt or Meta can run inside an editable field. These shortcuts can still conflict with text input: Windows can report AltGr as Ctrl+Alt, so a ctrl+alt+ focus key may fire while typing characters such as € or @.

When isComposing is true, no binding runs, including focus shortcuts. All keys remain available to the input method.

See editable targets for a demo.

Horizontal groups and RTL

Reading direction changes the default horizontal arrows. Moves still follow DOM order. Direction comes from the nearest dir attribute; a missing dir or dir="auto" falls back to computed style.

  • data-keyrove-orientation="horizontal" on a list makes ArrowRight and ArrowLeft the next and prev defaults. Under RTL they swap, so a toolbar reads "forward" the way its text does. A grid ignores the attribute: its cell axis is already horizontal.
  • A grid's default cell arrows follow the reading direction the same way. Under RTL, DOM order renders right-to-left, so ← is the next cell; each arrow keeps moving focus the way it points. The row arrows never flip.

Both rules apply to defaults only. An explicit binding is literal, never flipped or remapped, and wins over orientation wherever both are set. The default arrows it replaces go back to their browser behavior. See horizontal lists for both directions at work.

Focus keys

Set data-keyrove-focus-key="ctrl+shift+KeyE" on an element to focus it from anywhere under the listener, including sibling and nested roots. A combo with Ctrl, Alt or Meta also works inside an editable field. Bare codes work outside editable targets. The reported action is 'focus'.

  • Focus keys sit first in the binding table, so they win any collision with the root's bindings or the defaults. Two elements naming one combo resolve to the first in DOM order. The attribute scan excludes targets with an enabled data-keyrove-skip or a disabled attribute.
  • The element need not be an item. An item stays in its group's order, so the arrows reach it too; any other element is reached by its key alone.
  • On an item, the move happens in the item's own group: the nearest root above it, else the listener's element. An item that is itself a root belongs to the group above it. from is the item of that group holding focus, or null when focus was outside it.
  • On a non-item destination, from is null when jumping to it, and no roving stop moves. If focus is already inside it, from is that element and the result is a consumed no-op. Make a panel its inner list's root so an arrow after the shortcut enters that list.
  • Pressed while focus is already inside its element, the key is a consumed no-op: claimed, with to: null.
  • The element has to be able to take focus. A panel usually needs tabindex="-1", which also keeps it out of the Tab order. On an element that cannot take focus, the key is the same consumed no-op.
  • On an item, the roving tab stop moves within the item's group, as for an arrow move: when the item focus leaves carries data-keyrove-roving-tabindex, it drops to tabindex="-1" and the target takes 0. When focus leaves an item of a nested group, that group keeps its stop, and the target takes its own group's stop. From outside the group keyRove leaves the stop where it was, though a followFocus listener then moves it to the item focus landed on.

See focus keys for the pattern at work.

Options

Use options to configure a group in JavaScript. keyRove reads them on each call, falling back to attributes and defaults for omitted settings. The options object is typed KeyRoveOptions. See configuration differences for naming, scope and replacement rules.

keyRove(e); // attributes and defaults
keyRove(e, { loop: true }); // override looping only
keyRove(e, { items: '[role="menuitem"]' }); // override item lookup only
OptionTypeFalls back toMeaning
itemsstring | (root: Element) => Element[]data-keyrove-itemThe group's items: a selector run inside the root, or a function returning them. Elements carrying disabled are never navigable.
rootstringdata-keyrove-rootSelector a root answers to, matched at or above the event's target.
colsnumber | 'auto'data-keyrove-colsColumns. Above 1 the group is a grid. 'auto' counts the root's CSS grid tracks on every keypress.
loopbooleandata-keyrove-loopWhether next/prev wrap at the ends. Lists only.
orientation'horizontal' | 'vertical'data-keyrove-orientation'horizontal' re-points a list's default arrows; see RTL.
pageLengthnumberdata-keyrove-page-lengthRows per page jump — items, in a list.
keysPartial<Record<StrideAction | 'exit' | 'enter', KeyCombo | 'none'>>the *-key attributesThe combo or combos each move answers to: { next: 'ArrowDown, KeyJ' }, or 'none' for no key. Read move by move. Includes exit and enter.
focusKeysRecord<string, string | Element>data-keyrove-focus-keyCombo, or a list of them, → element or a selector resolved within the listener's reach: { 'F6, ctrl+KeyE': '#panel' }. Replaces the attribute scan rather than adding to it.
skipstring | (element: Element) => booleandata-keyrove-skipWhich items a move passes over: a selector, or a predicate.
rovingTabindexbooleandata-keyrove-roving-tabindexWhether the group carries one tab stop. One boolean for the group, where the attribute is read per item.

keys falls back per action. { keys: { next: 'KeyJ' } } changes only next; other actions retain their attribute or default bindings. Empty, blank and comma-only bindings fall back; 'none' disables the binding.

A supplied focusKeys map, including {}, replaces the whole attribute scan. Its targets bypass skip checks, but disabled targets remain excluded. Selector targets resolve under the listener; element targets are used directly.

Numeric cols and pageLength options are rounded down to whole numbers. Values below 1 or NaN fall back to the attribute, then the default if the attribute is invalid or below 1. Defaults are one column and ten items or rows per page.

Supply computed values in the call, for example keyRove(e, { cols: columnsNow() }). See options in JavaScript and the shared settings accepted by createTypeahead.

options.onMove

Fired after focus has moved, and only when it actually moved. A consumed key with nowhere to go, at the end of a list or the edge of a grid, fires nothing, and neither does a move whose target does not take focus.

keyRove(e, {
  onMove: ({ action, from, to }) => console.log(action, from, to),
});

action is 'next', 'prev', 'home', 'end', 'pageUp' or 'pageDown'; 'nextRow', 'prevRow', 'homeRow' or 'endRow' in a grid; 'exit' or 'enter' between nested groups; or 'focus' for a shortcut. from is the item focus left, or null when no item was focused or the destination is a non-item. to is the element that received focus.

Return value

keyRove reports what it did with the key, so handlers compose:

  • null: the key was not keyrove's and is untouched, browser default included. This includes a key another handler already consumed.
  • { action, from, to }: the key was consumed. to is the newly focused element, or null for a consumed no-op, where the group owns the key but there is nowhere left to go, or the target did not take focus.

A non-null result means the key was consumed. Chain handlers with || to pass only unhandled keys to the next one:

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

The listbox chains three handlers this way.

A key is already consumed when an earlier handler called preventDefault() on the event. keyRove and typeahead return null for it without moving focus, so a component and an app shell can both call keyrove on the same bubbling event and focus moves once. keyrove never stops propagation, so ancestor listeners still receive the event. In a || chain, null still passes it to your next handler; check e.defaultPrevented there if your handler should skip consumed keys too.

rove(element, action, options?)

Moves focus by action name, without a keypress. Use it for on-screen next and previous buttons, gamepads and remotes, or to focus a group's first item with rove(list, 'home').

import { rove } from '@mixedrays/keyrove';

nextButton.addEventListener('click', () => rove(results, 'next'));
prevButton.addEventListener('click', () => rove(results, 'prev'));
ParameterTypeRequiredDescription
elementElement | null | undefinedYesWhere to find the group, as described below. Nullish returns null.
actionStrideActionYesThe move to make, such as 'next', 'home' or 'nextRow'.
optionsKeyRoveOptionsNoThe same options as keyRove; see below.

Returns a MoveResult, or null when there is no move to make.

action is a stride action: 'next', 'prev', 'home', 'end', 'pageUp' or 'pageDown', plus 'nextRow', 'prevRow', 'homeRow' and 'endRow' in a grid. Key bindings do not affect it. To focus a specific element, call element.focus(), and attach followFocus to a roving group.

The group is found as for a keypress, with element as both target and listener: the nearest root at or above element, else element itself.

The move starts from, in this order:

  1. The focused item. The move matches its key exactly: same result, roving stop and onMove.
  2. In a roving group, the item holding the tab stop. A button takes focus when pressed, so this lets repeated presses continue from the user's position.
  3. Otherwise, the move enters the group. home, next, nextRow and pageDown go to the first navigable item; end, prev, prevRow and pageUp go to the last. homeRow and endRow do nothing.

Row actions apply only to grids; in a list they do nothing.

It accepts the same options as keyRove, including onMove, and ignores keys and focusKeys.

It returns { action, from, to }, as a keypress does (see Return value). from is null when the move entered the group. to is null when there was nowhere to go or the target did not take focus. It returns null when there is no move to make: no group, no item to enter, or a row action in a list.

createTypeahead(options?)

Builds a keydown handler that focuses items as their labels are typed. Printable characters accumulate in a buffer, and focus jumps to the first navigable item whose label starts with it. Case is ignored, and so are accents and other combining marks: E reaches Émilie, and É reaches emilie.

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

const typeahead = createTypeahead();

list.addEventListener('keydown', (e) => keyRove(e) || typeahead(e));
ParameterTypeRequiredDescription
optionsTypeaheadOptionsNoThe typeahead settings below, and the group settings it shares.

Returns a keydown handler, (event: KeyRoveEvent) => TypeaheadResult | null.

Chain it after keyRove, as above, so a printable binding like KeyJ navigates instead of entering the buffer. Create one handler per listener: the buffer lives in the handler, which keeps keyRove itself stateless. See typeahead for it at work.

OptionTypeDefaultMeaning
label(item: Element) => string—The text an item is matched by. Falls through to the attribute and then the item's text where it returns nothing.
resetMsnumber500Milliseconds of typing silence after which the buffer clears.
matchMode'prefix' | 'cycle''prefix''cycle' moves each single-character press to the next matching item after focus, wrapping, so repeats cycle; see below.
foldDiacriticsbooleantrueIgnore accents and other combining marks on both sides of the match. Turn it off where an accent tells two items apart.
onMove(move: TypeaheadMove) => void—Fired after focus has moved, and only then; see keyRove's option.

It shares items, root, skip and rovingTabindex with keyRove, using the same fallback rules. Pass one configuration to both handlers:

const config = { items: '[role="menuitem"]', loop: true };
const typeahead = createTypeahead(config);

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

Typeahead ignores movement options: keys, cols, loop, orientation and pageLength. Its options are captured when the handler is created; recreate it to change them. Item queries, skip predicates and attribute fallbacks still read the current DOM. The complete roving setup shows it beside navigation, initialization and focus tracking.

Labels come from label(item), then data-keyrove-typeahead, then textContent. An empty value falls through to the next source. Text content is trimmed and consecutive whitespace is collapsed. Skipped and disabled items are excluded; a skip option replaces the attribute test. Roots resolve as in keyRove.

Bindings match the physical e.code; typeahead reads e.key, the character the key produced in the user's layout. A press is buffered only when it is typing:

  • a single character, with none of Ctrl, Alt or Meta held (Shift is allowed; it is how capitals are typed);
  • not inside an editable target;
  • a space only once the buffer holds a character, so the spacebar keeps scrolling and clicking.

The buffer clears after resetMs of silence, and whenever a press resolves to a different root than the last one, so one delegated handler serves several groups without carrying a prefix between them. There is no timer to clean up. A character that matches nothing is left to its browser default but stays in the buffer, so a mistyped prefix matches nothing more until the reset clears it.

The handler follows the keyRove contract: null when the key was left untouched or already consumed, which leaves the buffer unchanged, and { action: 'typeahead', from, to } when it was consumed, with to: null when the match is already focused or cannot receive focus. onMove fires after focus has moved and only when it actually moved, so both handlers can feed the same follow-focus logic. The roving tab stop moves with the match, as for an arrow move.

Cycle mode

With matchMode: 'cycle', a buffer holding a single character searches from the item after the focused one, in DOM order, and wraps past the last item. Pressing that character again does not grow the buffer, so each press moves on to the next item starting with it, whether or not the reset has passed in between. A different character typed before the reset still refines the prefix, and prefixes longer than one character match from the top as usual. The trade-off is that a label opening with a doubled letter, such as Aaron, cannot be found by typing "aa".

matchesCombo(event, combo)

Whether a keydown event matches a combo, typed KeyCombo. This is the matcher behind every key check keyrove makes, exported for your own handlers.

import { matchesCombo } from '@mixedrays/keyrove';

list.addEventListener('keydown', (e) => {
  if (matchesCombo(e, 'Escape')) closePanel();
  if (matchesCombo(e, 'mod+KeyK')) openPalette();
});
ParameterTypeRequiredDescription
eventKeyRoveEventYesThe keydown to test.
comboKeyComboYesA combo such as 'mod+KeyK', or a comma-separated list of them.

Returns true when the event matches the combo, or any combo in the list.

Matching is exact, so 'Escape' above rejects Ctrl+Escape. A list matches any of its combos, which saves writing the any-of by hand:

if (!matchesCombo(e, 'Space, Enter')) return null;

initRovingTabindex(root, options?)

Initializes or repairs a roving group's tab stop. Call it after rendering, and again after a render that may replace items. keyRove does not initialize the stop for you.

import { initRovingTabindex } from '@mixedrays/keyrove';

initRovingTabindex(list);
ParameterTypeRequiredDescription
rootElement | null | undefinedYesThe group's root. Nullish returns null.
optionsInitRovingTabindexOptionsNoitems, root, skip and rovingTabindex, plus initial, described below.

Returns the item holding the stop, or null.

Only the root's own roving items participate: items marked with data-keyrove-roving-tabindex, or those enabled by the options. Nested roots keep their own stops; initialize them separately.

The stop is chosen in this order:

  1. A valid initial item: one of the group's navigable roving items.
  2. The first navigable roving item that already has tabindex="0".
  3. The first navigable roving item.

Skipped and disabled items cannot hold the stop. All other roving items get -1, including skipped and disabled ones. Only changed attributes are written.

It returns the item holding the stop, or null when no roving item is navigable. Items that do not carry the stop keep whatever tabindex they have.

It shares items, root, skip and rovingTabindex with the other helpers, so you can pass the same configuration:

const config = { items: '[role="menuitem"]', rovingTabindex: true };

initRovingTabindex(menu, config);
menu.addEventListener('keydown', (e) => keyRove(e, config));

Use initial to choose a stop explicitly, such as a listbox's selected option:

initRovingTabindex(listbox, {
  initial: listbox.querySelector('[aria-selected="true"]'),
});

A valid initial overrides the existing stop. Pass it for first setup or an intentional external selection change. Omit it on routine renders to preserve the user's position. Null, skipped, disabled or non-roving items are ignored, using the fallback order above. The complete roving setup shows where each call belongs.

followFocus(event, options?)

Updates a roving group's tab stop when focus enters an item, including clicks, programmatic focus and Tab entering a control inside an item. Attach it to focusin so returning with Tab reaches the most recently focused item:

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

list.addEventListener('keydown', (e) => keyRove(e));
list.addEventListener('focusin', (e) => followFocus(e));
ParameterTypeRequiredDescription
eventPick<KeyRoveEvent, 'target' | 'currentTarget'>YesThe focusin event.
optionsRovingTabindexOptionsNoitems, root, skip and rovingTabindex.

Returns the item now holding the stop, or null.

  • The item is found the way a keypress finds its position: the nearest root above the target, and the item of its group holding focus. Focus on a control inside an item counts as focus on the item.
  • Where that root's own items hold no focus, the group around it is asked, as far as the listener's element. That covers a panel that is a root and also an item of the group around it, and a control of a nested root that sits inside an outer item.
  • The item takes the stop when it carries one and is not skipped, and every other roving item of its group gets -1. A nested group's stop is its own and is never touched.
  • Only attributes that change are written. After one of keyRove's own moves, which has already carried the stop, the focusin it causes writes nothing.

It returns the item now holding the stop, or null when focus is in no item that carries one. It takes the same group settings as initRovingTabindex: items, root, skip and rovingTabindex. See the complete roving setup for all the helpers wired together.

toggleTabIndex({ root, isActive })

Sets tabindex to 0 or -1 on a single element.

import { toggleTabIndex } from '@mixedrays/keyrove';

toggleTabIndex({ root: firstItem, isActive: true });
ParameterTypeRequiredDescription
rootElement | null | undefinedYesThe element to set. Nullish is a no-op.
isActivebooleanYestrue sets tabindex="0", false -1.

Returns nothing.

Use it where you manage one element's tab stop yourself. For a whole roving group, initRovingTabindex keeps exactly one 0 for you, and followFocus moves it with focus that keyRove did not move. Descendant tab stops are left alone; roving tabindex only needs the item itself to carry the stop. A nullish root is a no-op, so a query that found nothing needs no guard.

Attributes

The table below describes attribute configuration. The options table maps it to JavaScript; configuration differences explains scope and replacement exceptions.

On an item:

AttributeMeaning
data-keyrove-itemMarks an element as navigable. It is the position whenever focus is anywhere inside it.
data-keyrove-skipPassed over when moving; keeps its place in the sequence. disabled also excludes an element, but removes it from the sequence entirely, so in a grid it shifts the cells after it.
data-keyrove-roving-tabindexMoves the tabindex="0" tab stop along with focus.
data-keyrove-focus-keyCombo that focuses this element from anywhere under the listener, e.g. ctrl+shift+KeyE. Works without data-keyrove-item, too.
data-keyrove-typeaheadLabel for type-to-focus, when the item's own text is not it.

On the root, read on every keypress:

AttributeDefaultMeaning
data-keyrove-root—Marks the root explicitly, instead of the listener's element.
data-keyrove-cols1Column count; above 1 the group navigates as a grid. Values below 1 fall back to the default. auto counts the tracks of the root's CSS grid on every keypress; see responsive grid.
data-keyrove-page-length10Items per page jump; rows, in a grid. Values below 1 fall back to the default.
data-keyrove-loop—Next and prev wrap past the ends of a list. Grids never wrap. See looping lists.
data-keyrove-orientation—horizontal re-points a list's next/prev defaults at ArrowRight/ArrowLeft, RTL-aware. See horizontal lists.
data-keyrove-next-keyArrowDown; ArrowRight in a horizontal list or a gridNext item; the next cell, in a grid. E.g. KeyJ or ctrl+ArrowRight.
data-keyrove-prev-keyArrowUp; ArrowLeft in a horizontal list or a gridPrevious item.
data-keyrove-next-row-keyArrowDownNext row, same column. Grid only.
data-keyrove-prev-row-keyArrowUpPrevious row, same column. Grid only.
data-keyrove-home-keyHome; ctrl+Home in a gridFirst item; the grid's first cell.
data-keyrove-end-keyEnd; ctrl+End in a gridLast item; the grid's last cell.
data-keyrove-home-row-keyHomeFirst cell of the focused row. Grid only.
data-keyrove-end-row-keyEndLast cell of the focused row. Grid only.
data-keyrove-page-up-keyPageUpPage jump back.
data-keyrove-page-down-keyPageDownPage jump forward.
data-keyrove-exit-key—From inside this nested root to the group around it. See exit and enter.
data-keyrove-enter-key—From the focused item into the root nested inside 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.

Every *-key attribute takes none as well as a combo, which leaves its move with no key and its default key to the browser.

Horizontal default arrows reverse under RTL; see horizontal groups and RTL.

Attribute builders

rootAttributes(options?) and itemAttributes(options?) return plain objects of data-keyrove-* attributes with string values and literal property names in their types. Spread them into JSX or Svelte markup, use Vue's v-bind, or apply them with setAttribute; see framework examples.

import { rootAttributes, itemAttributes } from '@mixedrays/keyrove';

rootAttributes({ cols: 3, loop: false, keys: { next: 'KeyJ, ArrowDown' } });
// {
//   'data-keyrove-root': 'true',
//   'data-keyrove-cols': '3',
//   'data-keyrove-loop': 'false',
//   'data-keyrove-next-key': 'KeyJ, ArrowDown'
// }

itemAttributes({ skip: false, rovingTabindex: true, typeahead: 'Inbox' });
// {
//   'data-keyrove-item': 'true',
//   'data-keyrove-skip': 'false',
//   'data-keyrove-roving-tabindex': 'true',
//   'data-keyrove-typeahead': 'Inbox'
// }

Both builders always emit their enabled marker, including when called with no arguments. Booleans become "true" or "false", numbers become strings, and undefined fields are omitted. They do not set tabindex; give each item a tab stop or initialize roving tabindex.

The root input shares its fields and types with GroupOptions. Move bindings accept the same combos, comma-separated lists and 'none'. Bindings are open KeyCombo strings, as in GroupOptions; the builders do not validate their spelling. Per-item skip and rovingTabindex are booleans, unlike group-wide selectors and settings. The public input and output types are:

type RootAttributeOptions = Pick<
  GroupOptions,
  'cols' | 'loop' | 'orientation' | 'pageLength' | 'keys'
>;

type ItemAttributeOptions = {
  skip?: boolean;
  rovingTabindex?: boolean;
  focusKey?: KeyCombo;
  typeahead?: string;
};

// RootAttributes and ItemAttributes name the returned object types.

items, the root selector, group-wide skip and rovingTabindex, and focusKeys stay options; they have no corresponding root attribute.

For plain DOM code:

for (const [name, value] of Object.entries(rootAttributes({ loop: true }))) {
  list.setAttribute(name, value);
}

The builders have no DOM dependency and can run during server rendering. Unused builders are tree-shaken away with the rest of the package's unused exports.

Constants

Every attribute name is exported as a constant, so markup built in JavaScript need not hardcode strings. Drop data-keyrove-, upper-case the rest with _ for -, and prefix KEYROVE_ATTR_: data-keyrove-next-row-key is KEYROVE_ATTR_NEXT_ROW_KEY.

import { KEYROVE_ATTR_ITEM } from '@mixedrays/keyrove';

const item = document.createElement('li');
item.setAttribute(KEYROVE_ATTR_ITEM, '');
item.tabIndex = 0;

Types

import type {
  GroupOptions,
  KeyCombo,
  KeyRoveCode,
  KeyRoveEvent,
  KeyRoveOptions,
  Move,
  MoveAction,
  MoveResult,
  InitRovingTabindexOptions,
  ItemAttributeOptions,
  ItemAttributes,
  Options, // the earlier name of KeyRoveOptions
  RootAttributeOptions,
  RootAttributes,
  RovingTabindexOptions,
  StrideAction,
  TypeaheadMove,
  TypeaheadOptions,
  TypeaheadResult,
} from '@mixedrays/keyrove';

KeyRoveEvent

The required event shape. Native keyboard events and compatible framework events, including React synthetic events, satisfy it.

type KeyRoveEvent = {
  code: KeyRoveCode;
  target: EventTarget | null;
  currentTarget: EventTarget | null;
  preventDefault: () => void;
  ctrlKey?: boolean;
  altKey?: boolean;
  shiftKey?: boolean;
  metaKey?: boolean;
  isComposing?: boolean;
  defaultPrevented?: boolean;
  key?: string;
};

The modifier flags, isComposing, defaultPrevented and key are optional so a hand-built event object still qualifies. A missing flag reads as "not held", a missing isComposing as "not composing" and a missing defaultPrevented as not consumed. An object of your own that bridges events must forward them, or every press matches as unmodified and unconsumed. Only typeahead reads key; an event without it navigates but never typeaheads.

KeyRoveCode

The code an event carries: the physical key, such as KeyJ or ArrowDown. Any string is accepted; the union exists so editors complete the codes keyrove binds by default. It does not validate. Bindings, which add modifiers and lists, use KeyCombo.

type KeyRoveCode =
  | 'ArrowUp'
  | 'ArrowDown'
  | 'ArrowLeft'
  | 'ArrowRight'
  | 'Home'
  | 'End'
  | 'PageUp'
  | 'PageDown'
  | (string & {});

KeyCombo

A binding string: one combo, such as 'KeyJ' or 'ctrl+ArrowDown', or a comma-separated list of them, such as 'ArrowDown, KeyJ'. It types the keys values, focusKey in the attribute builders, and the second argument of matchesCombo. Where a move is bound, 'none' binds it to no key and returns the default key to the browser.

type KeyCombo = KeyRoveCode; // the same open string, in a binding's role

const next: KeyCombo = 'ArrowDown, ctrl+KeyJ';
const options: KeyRoveOptions = { keys: { next, pageDown: 'none' } };

Like KeyRoveCode, it accepts any string, including values read from attributes or data, and does not validate the grammar.

MoveAction, MoveResult, Move

type MoveAction =
  | 'home'
  | 'end'
  | 'homeRow' // grid only: first cell of the focused row
  | 'endRow' // grid only: last cell of the focused row
  | 'next' // +1 item in DOM order: a list item, or a grid cell
  | 'prev'
  | 'nextRow' // grid only: +1 row, same column
  | 'prevRow'
  | 'pageUp'
  | 'pageDown'
  | 'exit' // from a nested root to the group around it
  | 'enter' // from an item into the root nested inside it
  | 'focus'; // an element's own data-keyrove-focus-key

// what keyRove returns for a consumed keypress
type MoveResult = {
  action: MoveAction;
  from: Element | null;
  to: Element | null;
};

// what onMove receives: a move that actually happened
type Move = MoveResult & { to: Element };

KeyRoveOptions

What keyRove and rove take: the group settings and onMove. Options is its earlier name and remains exported as the same type.

type KeyRoveOptions = GroupOptions & { onMove?: (move: Move) => void };

type Options = KeyRoveOptions;

GroupOptions, StrideAction

All group settings are optional; see options for fallbacks. StrideAction covers movement through the item sequence. It excludes exit, enter and focus.

type GroupOptions = {
  items?: string | ((root: Element) => Element[]);
  root?: string;
  cols?: number | 'auto';
  loop?: boolean;
  orientation?: 'horizontal' | 'vertical';
  pageLength?: number;
  keys?: Partial<Record<StrideAction | 'exit' | 'enter', KeyCombo | 'none'>>;
  focusKeys?: Record<string, string | Element>;
  skip?: string | ((element: Element) => boolean);
  rovingTabindex?: boolean;
};

type StrideAction = Exclude<MoveAction, 'exit' | 'enter' | 'focus'>;

TypeaheadOptions, TypeaheadResult, TypeaheadMove

What createTypeahead takes and its handler returns. The result has MoveResult's shape with its own action.

type TypeaheadOptions = Pick<
  GroupOptions,
  'items' | 'root' | 'skip' | 'rovingTabindex'
> & {
  label?: (item: Element) => string; // the text an item is matched by
  resetMs?: number; // buffer lifetime, default 500
  matchMode?: 'prefix' | 'cycle'; // how repeated characters match, default 'prefix'
  foldDiacritics?: boolean; // ignore accents and other marks, default true
  onMove?: (move: TypeaheadMove) => void;
};

type TypeaheadResult = {
  action: 'typeahead';
  from: Element | null;
  to: Element | null; // null: the match is the item already focused
};

// what onMove receives: a move that actually happened
type TypeaheadMove = TypeaheadResult & { to: Element };

RovingTabindexOptions, InitRovingTabindexOptions

What followFocus and initRovingTabindex take: the group settings that decide which elements are the group's roving items, and for initRovingTabindex, the item to give the stop.

type RovingTabindexOptions = Pick<
  GroupOptions,
  'items' | 'root' | 'skip' | 'rovingTabindex'
>;

type InitRovingTabindexOptions = RovingTabindexOptions & {
  initial?: Element | null; // the item to hold the stop, when it can
};