Tab is for moving between widgets. Arrows are for moving inside one. A toolbar, listbox or menu should add exactly one stop to the page's tab order, not one per item.
Swept over item counts 1…50, the roving widget contributes 1 tab stop at every single size — 50 summed. The default markup, which is what you get by writing no tabindex at all, contributes 1…50 — 1,275 summed.
👉 Live, runs in your browser: https://dev48v.infy.uk/design/day80-roving-tabindex.html
The whole pattern, and the whole correctness story
// the whole pattern, in two lines
const tabIndexOf = (state, i) => (i === state.activeIndex ? 0 : -1);
const next = (items, from, dir, wrap) => /* first ENABLED index that way */;
// and the whole correctness story, in one
items.filter((_, i) => tabIndexOf(state, i) === 0).length === 1
Not "usually one". If it is ever zero, Tab skips the widget and a keyboard user can never get in. If it is ever two, the widget has quietly grown a second tab stop and the bug is invisible to everyone using a mouse.
Enumerated, not sampled — and the reason is one design decision
Over 240 enumerated configurations (item counts × disabled patterns × wrap), 8,892 distinct reachable states and 79,968 key events:
| assertion | checks | held | broke |
|---|---|---|---|
| exactly one item at tabindex 0, after every key | 80,208 | 80,208 | 0 |
| the active item is never a disabled item | 80,208 | 80,208 | 0 |
| the active index is in range, or exactly −1 | 80,208 | 80,208 | 0 |
That walk is exhaustive rather than sampled, and it is exhaustive only because of one decision: buffer expiry arrives as a Timeout key, not a setTimeout. A state machine that reads the clock cannot be walked — you can only poke at it. Making time an input is what turns "we tried a few" into a count.
The edges are in by construction, not by luck: the first item disabled, the last item disabled, everything disabled but one. 54 of the 240 configurations have nothing focusable at all, and those are scored against a different rule — zero tab stops, not one — because "exactly one" is the wrong assertion for a widget with nothing to focus.
The qualifier that usually gets deleted instead of the bug
Three identities, 702 checks, 0 broken:
-
wrap=true: one ArrowRight per enabled item returns you home — the moves are a cyclic group (234 starting points). -
wrap=false: both ends absorb; arrowing past the last enabled item is a no-op (186 checks). - ArrowRight then ArrowLeft returns to the same index whenever no end was crossed (282 pairs).
That last qualifier is load-bearing, and 186 pairs are deliberately excluded because an end really was crossed. Under wrap=false the pair is genuinely not reversible at the ends: the first press was absorbed and the second one moves. Asserting reversibility without the qualifier fails on a correct implementation — which is how the qualifier gets deleted instead of the bug.
Typeahead is a search, and it has its own edges
Type b and you land on the next item starting with b; type it again and you cycle; type b then o and you get "Bo…", not the next o. So the buffer decides the needle. Two consequences the page counts rather than hopes for: across 1,302 typeahead searches, the number that landed on a disabled item is 0 — a search that puts you somewhere you cannot act is worse than one that finds nothing — and typeahead wraps even when the arrows do not, because clamping is a statement about a direction and a search does not have one.
aria-activedescendant is the other answer, not the wrong one
It ties roving at 1 tab stop, at every size. The difference is where focus is. With roving the item is really focused, so :focus and :focus-visible style it for free and document.activeElement is the thing the user is on. With activedescendant the item is never focused: you draw the ring yourself, and the element the browser thinks is focused and the element the user is on are two different elements, permanently. What that buys is items that never need to be focusable — which is why comboboxes reach for it, since one cannot move DOM focus into its list without taking it out of the input.
What this models and what it does not
The engine is the keyboard state machine and nothing else. Nothing on this page was measured in a browser — the keystroke figures are counts of engine transitions, not timings. Out of scope, each of which is real work in a real widget: actually applying the tabindex and calling .focus(); items added or removed while the widget is focused, where the active index goes stale; RTL, which changes what ArrowLeft means; 2-D grids, where Home/End have both a row meaning and a grid meaning; and Page Up / Page Down.
23 in-page checks, 112 verifier asserts, 0 failures.