Build an accessible tabs widget. What roles and ARIA link a tab to its panel, and what's the keyboard model?
The tabs roles/relationships, the single-tab-stop arrow model, and automatic vs manual activation.
Roles and relationships: a container with role="tablist" holds the tabs; each tab is role="tab" with aria-selected (true on the active one, false on the rest) and aria-controls pointing at the id of its panel; each panel is role="tabpanel" with aria-labelledby pointing back at its tab's id. That two-way wiring lets a screen reader announce 'tab, 2 of 3, selected' and, on entering the panel, name it from its tab. Keyboard model (composite widget, so a single tab stop via roving tabindex): Tab moves focus INTO the active tab and, again, OUT to the tabpanel — not between tabs. LEFT/RIGHT arrows (or Up/Down for vertical tabs) move between tabs, wrapping at the ends; Home/End jump to first/last. The panel itself is often given tabindex="0" so that if it has no focusable content the user can still Tab to it and read it. A key design choice: AUTOMATIC activation (selecting a tab the instant arrow keys land on it) vs MANUAL activation (arrow keys move focus, Enter/Space activates). Automatic is fine and slightly faster when switching panels is cheap and instant; MANUAL is required when activating a tab is expensive (loads data, heavy render) so arrowing through tabs doesn't fire a load per keystroke. Only ONE panel is visible at a time; hide the inactive panels with hidden (not just CSS) so their content is out of the tab order and the accessibility tree. Don't reconstruct tabs from plain links/buttons without these roles — the relationships and the arrow-key model are exactly what AT users expect from something announced as 'tab'.
Building a tabbed interface; wiring tab↔panel relationships and choosing automatic vs manual activation.
function Tabs({ tabs }) {
const [sel, setSel] = React.useState(0);
function onKeyDown(e) {
if (e.key === 'ArrowRight') setSel((sel + 1) % tabs.length);
else if (e.key === 'ArrowLeft') setSel((sel - 1 + tabs.length) % tabs.length);
}
return (
<div>
<div role="tablist" onKeyDown={onKeyDown}>
{tabs.map((t, i) => (
<button key={t.id} role="tab" id={'tab-' + t.id}
aria-selected={i === sel}
aria-controls={'panel-' + t.id}
tabIndex={i === sel ? 0 : -1} // roving: one tab stop
onClick={() => setSel(i)}>{t.label}</button>
))}
</div>
{tabs.map((t, i) => (
<div key={t.id} role="tabpanel" id={'panel-' + t.id}
aria-labelledby={'tab-' + t.id}
hidden={i !== sel} tabIndex={0}>{t.content}</div>
))}
</div>
);
}