Skip to content

Roving Tabindex

A composite widget (list, toolbar, tab bar) should be one tab stop. Arrow keys move focus between items. Tab moves focus out of the widget entirely.

The pattern

HTML

One item gets tabindex="0" (the roving focus target). All others get tabindex="-1":

html
<nav role="listbox" aria-label="Sections">
  <button role="option" tabindex="0">First</button>
  <button role="option" tabindex="-1">Second</button>
  <button role="option" tabindex="-1">Third</button>
</nav>

Moving focus

On arrow key, move tabindex="0" to the next item and focus it:

javascript
function setRovingTab(container, next) {
  for (const btn of
    container.querySelectorAll("button")
  ) {
    btn.tabIndex = -1;
  }
  next.tabIndex = 0;
  next.focus();
}

Keyboard handler

Listen on the container, not individual items:

javascript
container.addEventListener("keydown", (event) => {
  const buttons = [
    ...container.querySelectorAll(
      "button:not(:disabled)",
    ),
  ];
  if (!buttons.length) return;
  const idx = buttons.indexOf(
    document.activeElement,
  );
  let next;

  switch (event.key) {
    case "ArrowDown":
    case "ArrowRight":
      event.preventDefault();
      next = buttons[
        idx + 1 < buttons.length ? idx + 1 : 0
      ];
      break;
    case "ArrowUp":
    case "ArrowLeft":
      event.preventDefault();
      next = buttons[
        idx - 1 >= 0 ?
          idx - 1 : buttons.length - 1
      ];
      break;
    case "Home":
      event.preventDefault();
      next = buttons[0];
      break;
    case "End":
      event.preventDefault();
      next = buttons[buttons.length - 1];
      break;
    default:
      return;
  }

  setRovingTab(container, next);
});

Arrow keys wrap around. Home and End jump to the first and last visible item. Disabled items are skipped by the :not(:disabled) selector.

Syncing with external state

When the active item changes from outside the widget (e.g., scrolling updates the current section), move the roving tabindex to match:

javascript
onSectionChange((section) => {
  const btn = container.querySelector(
    `[data-id="${section.id}"]`,
  );
  if (btn) setRovingTab(container, btn);
});

This keeps keyboard navigation in sync with the visible state without stealing focus.

Click vs keyboard

When an item is clicked, activate it and return focus to the main content area. When an item is reached via arrow keys, activate it but keep focus in the widget so the user can continue navigating:

javascript
// Click handler — activate, then move focus out
container.addEventListener("click", (event) => {
  const btn = event.target.closest("button");
  if (!btn || btn.disabled) return;
  activate(btn);
  setRovingTab(container, btn);
  mainInput.focus();
});

// Keyboard handler — activate, keep focus in widget
switch (event.key) {
  case "ArrowDown":
    // ...
    setRovingTab(container, next);
    activate(next);
    // no focus change — already on next
    break;
}

Roles

Widget typeContainer roleItem role
Selection listlistboxoption
Tab bartablisttab
Toolbartoolbar(none needed)
Menumenumenuitem