Roving tabindex

Opt a group into being a single tab stop, so Tab moves past it rather than through it — and what happens if you do not.

keyrove never interferes with Tab, so by default a group behaves exactly as its markup says: every item with tabindex="0" is a tab stop the browser walks through, and the bound keys move between the same items. Two navigation models over one list, neither aware of the other.

That default is right for a short group and wrong for a long one — twenty items should not be twenty stops on the way through a page. The roving tabindex pattern gives the group exactly one tab stop and moves it to whichever item was last focused.

It is opt-in, per item. Items carrying data-keyrove-roving-tabindex join in: when focus moves, keyrove sets tabindex="-1" on the item leaving and tabindex="0" on the one arriving. Items without the attribute keep whatever tabindex you gave them.

The group holds one tabindex="0" to begin with, on the first navigable item; every other item starts at -1. Tab into the list, arrow to an item, then Tab away and back — focus returns to where you left it.

  • Option 1
  • Option 2
  • Option 3
  • Option 4
  • Option 5
  • Option 6
Waiting for a keypress…
<ul id="options">
  <li data-keyrove-item data-keyrove-roving-tabindex tabindex="0">Option 1</li>
  <li data-keyrove-item data-keyrove-roving-tabindex tabindex="-1">Option 2</li>
  <li data-keyrove-item data-keyrove-roving-tabindex tabindex="-1">Option 3</li>
  <!-- … -->
  <li data-keyrove-item data-keyrove-roving-tabindex tabindex="-1">Option 6</li>
</ul>

Setting the initial tab stop

keyrove moves an existing tab stop; it does not create one. If the list is rendered from data, set the first one yourself — either in the template, or with the exported helper:

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

const [first] = list.querySelectorAll('[data-keyrove-item]');
toggleTabIndex({ root: first, isActive: true });

The same call restores the tab stop after a re-render drops it, which is the usual reason a roving group stops being reachable by Tab.

A group with every item at tabindex="-1" is unreachable by keyboard entirely — which is the one way this pattern can leave a page less navigable than the plain tab order it replaced. Exactly one 0 per group, always.

Skipped items and the first stop

data-keyrove-skip keeps an item out of the navigation order, so the initial tab stop belongs on the first item that is not skipped. Putting it on a skipped heading leaves Tab landing somewhere the navigation keys will immediately move away from.

Choosing between the two

Neither is more correct; they answer different questions.

Plain tab stops Roving tabindex
Tab Steps through every item Steps past the whole group
Bound keys Move between items Move between items
Cost to the page One stop per item One stop per group
Setup tabindex="0" on each item One 0, the rest -1, plus the attribute

Reach for plain tab stops when the items are few, or when each one is a destination a user might reasonably tab to — a row of three toolbar buttons, a short menu. Reach for roving tabindex when the group is long, or when it is one control conceptually rather than many: a listbox, a grid, a tab list. The ARIA authoring practices describe the second arrangement for composite widgets, and it is also what makes nested groups escapable — Tab moves from the inner group to the next thing without any code of yours.