core accessibility principles for modern frameworks

Roving Tabindex for Navigation Menus

Roving tabindex is the technique that turns a group of controls into a single tab stop: exactly one item has tabindex="0", every other item has tabindex="-1", and arrow keys move both the tabindex and the DOM focus. It is what makes a toolbar, a tab list or a menubar feel like one widget rather than fifteen. It is also frequently applied to things that should have stayed fifteen tab stops — which is why this guide covers when not to use it as carefully as how.

It expands one section of Accessible Navigation & Landmark Structure, where the choice between links, disclosures and menus is made.

WCAG Success Criteria Addressed:

  • 2.1.1 Keyboard
  • 2.4.3 Focus Order
  • 2.4.7 Focus Visible
  • 4.1.2 Name, Role, Value
Roving tabindex compared with individually tabbable items Two rows of five items. In the top row every item has tabindex zero, so Tab visits all five and reaching the content after them takes five presses. In the bottom row only the third item has tabindex zero and the rest have minus one, so Tab reaches the group once and arrow keys move between items; leaving the group takes a single further Tab press. A caption notes that the item with tabindex zero is also the one that receives focus when the group is re-entered. Every item tabbable — five tab stops Item 1 tabindex="0" Item 2 tabindex="0" Item 3 tabindex="0" Item 4 tabindex="0" Item 5 tabindex="0" Tab → Tab → Tab → Tab → Tab → content after the group Roving tabindex — one tab stop, arrows inside Item 1 tabindex="-1" Item 2 tabindex="-1" Item 3 tabindex="0" Item 4 tabindex="-1" Item 5 tabindex="-1" Tab → group · ← → move inside · Tab → content after the group The item holding tabindex="0" is remembered: re-entering the group returns to where the user left off.

Prerequisites

  • A group of controls that genuinely behaves as one widget — a toolbar, tab list, menubar or listbox — rather than a list of independent links.
  • React 18+ with refs available for each item (the examples use a ref array).
  • A visible focus style strong enough to read at a glance, since the focus ring is now the user's only position indicator inside the group.

When Roving Tabindex Is the Right Answer

The technique exists to stop long groups of related controls from flooding the tab sequence. That benefit is real for a 20-button toolbar and imaginary for a five-link navigation bar.

Implementation Guidelines:

  • Use it for composite widgets the ARIA patterns define with one tab stop: tablist, toolbar, menubar/menu, radiogroup, listbox, tree and grid.
  • Do not use it for a plain list of navigation links. Links are individually meaningful destinations, and screen reader users expect to reach them from the links list and by Tab.
  • Consider the group size. Below roughly seven items the saving is negligible and the extra keyboard model is a liability.
  • If you adopt it, adopt the whole pattern — arrows, Home, End, and where applicable typeahead. A half-implemented widget is worse than no widget.

The Core Implementation

Two pieces of state do all the work: which index is active, and whether focus should follow. Everything else is bookkeeping.

Implementation Guidelines:

  • Keep the active index in state; derive each item's tabIndex from it rather than mutating the DOM directly.
  • After the index changes in response to a key press, call focus() on the newly active item. Do not call it on every render, or you will steal focus during unrelated updates.
  • Wrap at the ends — ArrowRight on the last item goes to the first — unless the pattern says otherwise. Wrapping is the behaviour most users expect from a toolbar.
  • Match arrow direction to visual orientation: left/right for a horizontal group, up/down for a vertical one, and set aria-orientation accordingly.
'use client';
import { useRef, useState } from 'react';

export function Toolbar({ items }: { items: ToolbarItem[] }) {
  const [activeIndex, setActiveIndex] = useState(0);
  const refs = useRef<Array<HTMLButtonElement | null>>([]);

  const move = (nextIndex: number) => {
    const index = (nextIndex + items.length) % items.length; // wrap both ways
    setActiveIndex(index);
    // A11y rationale: 2.4.7 Focus Visible — the roving index is only half the
    // job; DOM focus must follow so the ring moves with it.
    refs.current[index]?.focus();
  };

  const onKeyDown = (event: React.KeyboardEvent) => {
    switch (event.key) {
      case 'ArrowRight': move(activeIndex + 1); break;
      case 'ArrowLeft': move(activeIndex - 1); break;
      case 'Home': move(0); break;
      case 'End': move(items.length - 1); break;
      default: return;
    }
    event.preventDefault(); // stop the page scrolling under the widget
  };

  return (
    <div role="toolbar" aria-label="Text formatting" aria-orientation="horizontal" onKeyDown={onKeyDown}>
      {items.map((item, index) => (
        <button
          key={item.id}
          ref={(node) => { refs.current[index] = node; }}
          type="button"
          tabIndex={index === activeIndex ? 0 : -1}
          onClick={() => setActiveIndex(index)}
        >
          {item.label}
        </button>
      ))}
    </div>
  );
}

The event.preventDefault() call is easy to omit and immediately noticeable: without it, ArrowDown scrolls the page while also moving the active item, and Home/End jump to the top and bottom of the document.

Keeping Focus and State in Step

The single most common roving-tabindex bug is calling focus() at the wrong time — either before React has rendered the new tabIndex, or on every render regardless of cause.

Order of operations when an arrow key moves the active item A four-step sequence. The user presses ArrowRight. The handler updates the active index in state and calls preventDefault so the page does not scroll. React re-renders, moving tabindex zero to the new item and minus one to the old one. Only then is focus called on the new item, so the element is already tabbable when it receives focus. A warning below notes that calling focus in an effect that runs on every render steals focus during unrelated updates. 1 · Key press ArrowRight, and preventDefault() 2 · State activeIndex moves to the next item 3 · Render tabindex 0 moves, old item goes to -1 4 · Focus focus() on the newly active item The failure mode Calling focus() from an effect with no dependency guard runs it on every render — so an unrelated state update yanks focus back into the widget while the user is typing elsewhere.

Implementation Guidelines:

  • Call focus() inside the key handler, immediately after setActiveIndex. React batches the update but the DOM node already exists, so focusing it is safe.
  • If you prefer an effect, guard it with a ref that records whether the last index change came from the keyboard.
  • Reset the active index when the item list changes — pointing at index 4 of a three-item list leaves the group with no tabbable member and no way in.
  • On click, update the index too. Otherwise the mouse and keyboard disagree about where the user is.
// If you must use an effect, make it fire only for keyboard-driven moves.
const cameFromKeyboard = useRef(false);

useEffect(() => {
  if (!cameFromKeyboard.current) return;
  cameFromKeyboard.current = false;
  refs.current[activeIndex]?.focus();
}, [activeIndex]);

Typeahead and Larger Groups

Once a group exceeds ten or so items, arrow keys alone become tedious. Typeahead — jump to the first item starting with the typed characters — is part of the menu and listbox patterns for that reason.

Implementation Guidelines:

  • Accumulate printable characters in a buffer and clear it after roughly 500 ms of inactivity.
  • Match case-insensitively against each item's visible text, starting the search after the current index so repeated presses cycle through matches.
  • Ignore modifier combinations, so Ctrl+F still reaches the browser.
  • Skip typeahead entirely for toolbars, where items are icons and there is nothing predictable to type.
How the typeahead buffer resolves a jump A timeline of three key presses. Pressing p sets the buffer to p and jumps to the first item starting with p, Playwright. Pressing r within five hundred milliseconds extends the buffer to p r and jumps to Prettier. After five hundred milliseconds of no typing the buffer clears, so the next p press starts a fresh search rather than extending the old one. press "p" buffer "p" → Playwright press "r" after 120 ms buffer "pr" → Prettier press "p" after 800 ms buffer cleared → fresh search 500 ms idle → buffer resets Only printable single characters extend the buffer, so Ctrl+F still reaches the browser's own find.
const buffer = useRef('');
const timer = useRef<number>();

function onTypeahead(key: string) {
  if (key.length !== 1 || !/\S/.test(key)) return;
  buffer.current += key.toLowerCase();
  window.clearTimeout(timer.current);
  timer.current = window.setTimeout(() => { buffer.current = ''; }, 500);

  const start = activeIndex + 1;
  const found = items.findIndex((item, offset) =>
    items[(start + offset) % items.length].label.toLowerCase().startsWith(buffer.current));
  if (found >= 0) move((start + found) % items.length);
}

How to Verify

  • Tab-count check. Tab through the page and confirm the group consumes exactly one stop. Two stops means an item still has tabindex="0" when it should not.
  • Return check. Move to item four with arrows, Tab away, then Shift+Tab back. Focus must return to item four, not item one — that is the whole point of moving the 0.
  • Focus-ring check. Arrow through every item and confirm the ring moves with the active index. A moving tabindex with a stationary ring means focus() is not being called.
  • Scroll check. Press ArrowDown and End inside the widget. If the page scrolls, preventDefault() is missing.
  • Automated scan. axe checks aria-required-children and aria-orientation values for composite roles; assert the tab count itself in Playwright, as shown in asserting focus order in Playwright.

Common Accessibility Mistakes

  • Applying it to a list of links. Navigation links belong in the tab order and in the links list. Roving tabindex hides them from both expectations for no benefit.
  • Leaving every item at tabindex="-1". If nothing holds 0, the group is unreachable by keyboard entirely — the most severe form of this bug, and easy to introduce when the list re-renders empty.
  • Moving tabindex without moving focus. The next Tab behaves correctly, but nothing appears to happen when the user presses an arrow key. Both must move together.
  • Forgetting preventDefault(). The page scrolls under the widget on every arrow press, and Home/End jump to the document ends.
  • Not resetting the index when items change. A stale index leaves the widget with no tabbable item, so keyboard users cannot enter it at all.
  • Omitting Home and End. In a long group, they are the difference between one key press and twenty.

Conclusion

Roving tabindex is a small technique with a strict contract: exactly one item at tabindex="0", arrows that move both the index and DOM focus, Home/End for the extremes, preventDefault() so the page stays still, and an index that survives re-renders. Implemented fully, it makes a dense widget feel like one control. Implemented halfway — or applied to a navigation bar that never needed it — it removes tab stops users were relying on and gives nothing back. Decide first whether the group really is one widget; only then reach for the pattern.

Frequently Asked Questions

Should a site's main navigation use roving tabindex? No. Navigation links are separate destinations, and users reach them through the tab sequence and the links list. The single-tab-stop model belongs to composite widgets — toolbars, tab lists, menubars — not to lists of links. The parent guide walks through that decision.

Can I use aria-activedescendant instead? Sometimes. It keeps DOM focus on a container and marks the active item by id, which suits comboboxes where focus must stay in a text input. For widgets where the items are themselves buttons, roving tabindex is simpler and more robust — real focus means real focus styling and no custom highlight to maintain.

What should happen at the ends of the list? Wrapping is the common expectation for toolbars and menus. Tab lists in the ARIA pattern also wrap. Where the group represents an ordered sequence with real ends — a paginated stepper, for example — stopping at the ends can be clearer. Pick one, and make it consistent across the product.

Do disabled items get skipped? Yes, if they are disabled with the disabled attribute — they cannot receive focus at all. If you use aria-disabled to keep them focusable and announce their state, include them in the arrow sequence but not in activation; that is the friendlier behaviour, since the user can discover why the item is unavailable.

How does this interact with a screen reader's own arrow keys? In browse mode, screen readers intercept arrow keys for reading. Composite widget roles switch most screen readers into focus/forms mode automatically, which hands the arrows back to your handler — another reason to give the container a proper role (toolbar, tablist, menu) rather than leaving it a <div>.