Keyboard users move around a page with the Tab key: each press moves focus, the place your key presses go, to the next control, and Shift+Tab moves it back. The tabindex attribute decides whether an element is a stop on that route. tabindex="0" puts it in the Tab order, at its place in the page. tabindex="-1" leaves it out, though a script can still focus it with element.focus().
A roving tabindex makes a group of controls, such as tabs, a toolbar, or a menu, a single stop. Only one item in the group has tabindex="0"; the others have tabindex="-1". Tab enters the group on that item, the arrow keys move focus inside the group, and the 0 moves, or roves, along with focus. The next Tab leaves the group. The WAI-ARIA Authoring Practices, the W3C's guide to accessible widgets, take this one-stop convention from desktop apps, and a roving tabindex is one of the two techniques they describe for it. Native radio buttons that share a name already work this way.
ARIA (Accessible Rich Internet Applications) is a set of attributes that tell assistive technology, such as screen readers, what a custom control is and what state it's in. In the tabs pattern, the container has role="tablist". Each tab has role="tab", aria-selected set to "true" on the selected tab and "false" on the others, and aria-controls holding its panel's id. Each panel has role="tabpanel" and aria-labelledby pointing back to its tab. A screen reader then announces the focused tab's name, that it's a tab, and whether it's selected. ARIA changes only what's announced: role="tab" adds no key handling, so the arrow keys are up to your code.
Here's a tab list with three tabs, Overview, Specs, and Reviews. Overview starts selected, and moving to a tab selects it:
| Key press | Focus moves to | tabindex of Overview, Specs, Reviews |
|---|---|---|
| None yet | Focus is on a link above the tabs | 0, -1, -1 |
| Tab | Overview, the one tab with 0 | 0, -1, -1 |
| ArrowRight | Specs, which is now selected | -1, 0, -1 |
| ArrowRight | Reviews | -1, -1, 0 |
| ArrowRight | Overview: it wraps around | 0, -1, -1 |
| End | Reviews, the last tab | -1, -1, 0 |
| Tab | The Reviews panel: one Tab leaves the group | -1, -1, 0 |
| Shift+Tab | Reviews, the tab with 0, not Overview | -1, -1, 0 |
ArrowLeft goes the other way, and Home goes to the first tab. When showing a panel is slow, for example because it loads data, the arrows can move focus without selecting, and Enter or Space selects the focused tab. Either way, a keyboard user crosses the whole tab list with one press of Tab, not one per tab, and lands on the tab whose panel is showing. A screen reader user who hears "tab" knows the arrow keys will work, as they do for tabs in desktop apps.
The page has a tab list. Each tab is a <button role="tab"> whose aria-controls holds the id of its panel:
<div role="tablist" aria-label="Settings">
<button type="button" role="tab" id="tab-profile" aria-controls="panel-profile">Profile</button>
<button type="button" role="tab" id="tab-billing" aria-controls="panel-billing">Billing</button>
<button type="button" role="tab" id="tab-team" aria-controls="panel-team">Team</button>
</div>
<div role="tabpanel" id="panel-profile" aria-labelledby="tab-profile" tabindex="0">Your name and photo.</div>
<!-- a panel for each tab -->
Write setupTabs(tablist). Selecting a tab sets its aria-selected to "true" and the other tabs' to "false". It shows the tab's panel by removing hidden, and hides every other tab's panel with hidden.
Selecting a tab also gives it tabindex="0" and the other tabs tabindex="-1": a roving tabindex.
On setup, select the tab that already has aria-selected="true", or the first tab if none has. Clicking a tab selects it.
When a tab has focus, ArrowRight selects and focuses the next tab and ArrowLeft the previous one, wrapping around at the ends. Home goes to the first tab and End to the last. Read the key from event.key.
Call preventDefault() for those four keys so the page doesn't scroll, and leave every other key alone, Tab above all.
setupTabs(document.querySelector('[role="tablist"]'));
// Profile is selected and its panel shows
// focus Profile and press ArrowLeft: Team is selected and focused (it wraps)
const tabs = [...document.querySelectorAll('[role="tab"]')];
const panels = ["panel-profile", "panel-billing", "panel-team"].map((id) => document.getElementById(id));
setupTabs(document.querySelector('[role="tablist"]'));
expect(tabs.map((tab) => tab.getAttribute("aria-selected"))).toEqual(["true", "false", "false"]);
expect(panels.map((panel) => panel.hidden)).toEqual([false, true, true]);
const tabs = [...document.querySelectorAll('[role="tab"]')];
const panels = ["panel-profile", "panel-billing", "panel-team"].map((id) => document.getElementById(id));
tabs[1].setAttribute("aria-selected", "true");
setupTabs(document.querySelector('[role="tablist"]'));
expect(tabs.map((tab) => tab.getAttribute("aria-selected"))).toEqual(["false", "true", "false"]);
expect(panels.map((panel) => panel.hidden)).toEqual([true, false, true]);
const tabs = [...document.querySelectorAll('[role="tab"]')];
const panels = ["panel-profile", "panel-billing", "panel-team"].map((id) => document.getElementById(id));
setupTabs(document.querySelector('[role="tablist"]'));
fire(tabs[2], "click");
expect(tabs.map((tab) => tab.getAttribute("aria-selected"))).toEqual(["false", "false", "true"]);
expect(panels.map((panel) => panel.hidden)).toEqual([true, true, false]);
const tabs = [...document.querySelectorAll('[role="tab"]')];
setupTabs(document.querySelector('[role="tablist"]'));
expect(tabs.map((tab) => tab.getAttribute("tabindex"))).toEqual(["0", "-1", "-1"]);
fire(tabs[2], "click");
expect(tabs.map((tab) => tab.getAttribute("tabindex"))).toEqual(["-1", "-1", "0"]);
const tabs = [...document.querySelectorAll('[role="tab"]')];
const press = (key) => fire(document.activeElement, "keydown", { key });
setupTabs(document.querySelector('[role="tablist"]'));
tabs[0].focus();
press("ArrowRight");
expect(document.activeElement).toBe(tabs[1]);
expect(tabs[1].getAttribute("aria-selected")).toBe("true");
press("ArrowRight");
press("ArrowRight");
expect(document.activeElement).toBe(tabs[0]);
press("ArrowLeft");
expect(document.activeElement).toBe(tabs[2]);
expect(tabs.map((tab) => tab.getAttribute("aria-selected"))).toEqual(["false", "false", "true"]);
expect(document.getElementById("panel-team").hidden).toBe(false);
const tabs = [...document.querySelectorAll('[role="tab"]')];
const press = (key) => fire(document.activeElement, "keydown", { key });
setupTabs(document.querySelector('[role="tablist"]'));
fire(tabs[1], "click");
tabs[1].focus();
press("End");
expect(document.activeElement).toBe(tabs[2]);
expect(tabs[2].getAttribute("aria-selected")).toBe("true");
press("Home");
expect(document.activeElement).toBe(tabs[0]);
expect(tabs[0].getAttribute("aria-selected")).toBe("true");
const tabs = [...document.querySelectorAll('[role="tab"]')];
setupTabs(document.querySelector('[role="tablist"]'));
tabs[0].focus();
expect(fire(tabs[0], "keydown", { key: "ArrowRight" }).defaultPrevented).toBe(true);
expect(fire(tabs[1], "keydown", { key: "Tab" }).defaultPrevented).toBe(false);
expect(fire(tabs[1], "keydown", { key: "a" }).defaultPrevented).toBe(false);
expect(fire(tabs[1], "keydown", { key: "ArrowDown" }).defaultPrevented).toBe(false);
expect(document.activeElement).toBe(tabs[1]);
const tabs = [...document.querySelectorAll('[role="tab"]')];
document.body.append(document.getElementById("panel-profile"));
setupTabs(document.querySelector('[role="tablist"]'));
fire(tabs[1], "click");
expect(document.getElementById("panel-billing").hidden).toBe(false);
expect(document.getElementById("panel-profile").hidden).toBe(true);
expect(document.getElementById("panel-team").hidden).toBe(true);
Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.
Ctrl + Enter runs the tests (⌘ + Enter on a Mac).
I'm here to help with Tabs with arrow-key navigation. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.