QuestionsAccessibility

Accessible tabs (tablist / tab / tabpanel)

ARIA WidgetsHardAccessibility

Build an accessible tabs widget. What roles and ARIA link a tab to its panel, and what's the keyboard model?

What it tests

The tabs roles/relationships, the single-tab-stop arrow model, and automatic vs manual activation.

Approach & answer

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'.

Use this technique when

Building a tabbed interface; wiring tab↔panel relationships and choosing automatic vs manual activation.

Code

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>
  );
}

References