Appearance
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 type | Container role | Item role |
|---|---|---|
| Selection list | listbox | option |
| Tab bar | tablist | tab |
| Toolbar | toolbar | (none needed) |
| Menu | menu | menuitem |