---
title: Tabs
source: 'https://academia.sh/en/courses/accessible-patterns/tabs'
course: 'Accessible Component Patterns'
language: en
updated: '2026-08-19T05:19:52+00:00'
license: 'CC BY-SA 4.0'
---

# Tabs

Tying tab headings to panels, the roving tabindex requirement, the decision between automatic and manual activation, and testing the selection invariant.

A card was the sum of the controls inside it, and it carried no behavior on its own. The
record detail page is different: the citation, the available copies, and the borrowing
history are three separate sections, only one is shown at a time, and the container
itself decides which one.

This pattern has two requirements, and neither is met by default. The user has to know
that three headings exist and which one is active, and has to be able to move between
the three with the keyboard. This lesson specifies the tab pattern.

## The Problem the Pattern Solves

Tabs let **equivalent, mutually exclusive** sections of content share the same space. All
three conditions are required: the sections sit at the same level, only one is meaningful
at a time, and the user switches between them often.

If the conditions are not met, the pattern is wrong. Sections that must be read in order
— the three steps of the borrowing form — are not tabs; the user has to complete the
first step before moving to the second. Sections that must be read at the same time are
not tabs either: if reading the citation requires checking the number of copies
available, the two cannot be placed in separate tabs.

Content length is a measure too. More than five tabs causes the headings to overflow
into a scrolling heading strip, at which point the pattern has turned into a navigation
list and should not be built as tabs.

## The Native Element First

The tab pattern has **no** native counterpart. It is built from three roles: the tab
strip `role="tablist"`, each heading `role="tab"`, and each section `role="tabpanel"`.

The headings are buttons; the role is written on top of the `button` element. The native
element brings everything except the role: focusability, operability by the Enter and
Space keys, and notifying the tree of the disabled state.

```html
<div role="tablist" aria-label="Record sections">
  <button role="tab" id="tab-citation" aria-selected="true"
          aria-controls="panel-citation" tabindex="0">Citation</button>
  <button role="tab" id="tab-copies" aria-selected="false"
          aria-controls="panel-copies" tabindex="-1">Available copies</button>
  <button role="tab" id="tab-history" aria-selected="false"
          aria-controls="panel-history" tabindex="-1">Borrowing history</button>
</div>

<div role="tabpanel" id="panel-citation" aria-labelledby="tab-citation" tabindex="0">…</div>
<div role="tabpanel" id="panel-copies" aria-labelledby="tab-copies" tabindex="0" hidden>…</div>
<div role="tabpanel" id="panel-history" aria-labelledby="tab-history" tabindex="0" hidden>…</div>
```

The bond is **two-way**: the tab points to the panel with `aria-controls`, the panel
points to the tab with `aria-labelledby`. The second direction is where the name comes
from — the panel's accessible name is the tab's text, and it is not written separately.
The strip's `aria-label` declaration is also required, because a page can hold more than
one tab strip and the reader has to be able to tell them apart.

Panels carrying `tabindex="0"` exists so that a panel with no focusable content inside it
can still be reached by keyboard. If the panel holds a list or a table, this value is
unnecessary; the user is already going to enter it.

## The Roving Tabindex Is Mandatory Here

Only one heading in the tab strip sits in the tab order; the rest carry a `tabindex`
value of minus one. When the selection changes, these values are updated together.

The reason is numeric. In a three-tab strip, laying out the pattern saves two stops. But
because the pattern's contract reserves the arrow keys for moving between headings, if
`Tab` also advanced within the strip, the two navigation paths would overlap and the user
could not learn what each key does. The roving tabindex leaves the `Tab` key exactly one
meaning: leave the strip.

## Automatic or Manual?

When an arrow key moves focus, should the selection move with it? Both decisions are
defined.

In **automatic activation**, whichever heading focus lands on, that panel opens. As the
user moves through the three tabs with the arrow keys, they see all three panels; the
key count is low.

In **manual activation**, the arrow key only moves focus; the panel opens with the Enter
or Space key. This costs two key presses.

The deciding measure is whether the panel content is **ready instantly**. If the
borrowing history is fetched from the server, automatic activation makes the user start
three separate requests while moving around; the screen changes on every arrow press, and
for a user who is not watching the screen, the panel content changes while focus is still
in the strip. If the panel content is already in the document, automatic activation is
preferred.

```js
// tab.test.mjs — the tab pattern's keyboard contract and panel relationship
// Run: node --test tab.test.mjs
import { test } from "node:test";
import assert from "node:assert/strict";

const TABS = ["Citation", "Available Copies", "Borrowing History"];

// mode: "automatic" | "manual"
const initial = (mode) => ({ mode, selected: 0, focus: { location: "list", i: 0 } });

function key(d, t) {
  const n = TABS.length;
  if (d.focus.location !== "list") {
    // The list's keys do not run while inside the panel
    return t === "Shift+Tab" ? { ...d, focus: { location: "list", i: d.selected } } : d;
  }
  const i = d.focus.i;
  const goTo = (y) => ({
    ...d,
    focus: { location: "list", i: y },
    selected: d.mode === "automatic" ? y : d.selected,
  });
  if (t === "ArrowRight") return goTo((i + 1) % n);
  if (t === "ArrowLeft") return goTo((i + n - 1) % n);
  if (t === "Home") return goTo(0);
  if (t === "End") return goTo(n - 1);
  if (t === "Enter" || t === "Space") return { ...d, selected: i };
  if (t === "Tab") return { ...d, focus: { location: "panel", i: d.selected } };
  return d;
}

const apply = (d, seq) => seq.reduce(key, d);

// The tab stop count the tab list occupies (roving tabindex)
const listStop = () => 1;

// The attributes the markup produces
function attributes(d) {
  return TABS.map((name, i) => ({
    name,
    role: "tab",
    "aria-selected": d.selected === i,
    "aria-controls": "panel-" + i,
    tabindex: d.focus.location === "list" && d.focus.i === i ? 0 : -1,
    panelHidden: d.selected !== i,
  }));
}

test("the tab list holds a single stop via the roving tabindex", () => {
  assert.equal(listStop(), 1);
  const attrs = attributes(initial("manual"));
  assert.equal(attrs.filter((o) => o.tabindex === 0).length, 1);
});

test("in automatic mode the arrow key moves focus and selection together", () => {
  const d = apply(initial("automatic"), ["ArrowRight"]);
  assert.equal(d.focus.i, 1);
  assert.equal(d.selected, 1);
});

test("in manual mode the arrow key only moves focus, Enter selects", () => {
  const a = apply(initial("manual"), ["ArrowRight", "ArrowRight"]);
  assert.equal(a.focus.i, 2);
  assert.equal(a.selected, 0);
  const b = apply(a, ["Enter"]);
  assert.equal(b.selected, 2);
});

test("arrow keys wrap to the start at the ends", () => {
  const right = apply(initial("manual"), ["ArrowLeft"]);
  assert.equal(right.focus.i, 2);
  const left = apply(initial("manual"), ["ArrowRight", "ArrowRight", "ArrowRight"]);
  assert.equal(left.focus.i, 0);
});

test("Home and End go to the first and last tab", () => {
  const d = apply(initial("manual"), ["ArrowRight", "Home"]);
  assert.equal(d.focus.i, 0);
  assert.equal(apply(initial("manual"), ["End"]).focus.i, 2);
});

test("Tab moves from the tab list to the selected panel, arrow keys do not run in the panel", () => {
  const d = apply(initial("manual"), ["ArrowRight", "Enter", "Tab"]);
  assert.deepEqual(d.focus, { location: "panel", i: 1 });
  const e = apply(d, ["ArrowRight", "Home"]);
  assert.deepEqual(e.focus, { location: "panel", i: 1 });
  assert.equal(e.selected, 1);
});

test("in every state exactly one tab is selected and only its panel is visible", () => {
  const KEYS = ["ArrowRight", "ArrowLeft", "Home", "End", "Enter", "Tab", "Shift+Tab"];
  const key2 = (d) => [d.mode, d.selected, d.focus.location, d.focus.i].join("|");
  let count = 0;
  for (const mode of ["automatic", "manual"]) {
    const b = initial(mode);
    const seen = new Map([[key2(b), b]]);
    const queue = [b];
    while (queue.length) {
      const d = queue.shift();
      for (const t of KEYS) {
        const y = key(d, t);
        if (seen.has(key2(y))) continue;
        seen.set(key2(y), y);
        queue.push(y);
      }
    }
    for (const d of seen.values()) {
      const attrs = attributes(d);
      assert.equal(attrs.filter((o) => o["aria-selected"]).length, 1, "more than one tab selected");
      assert.equal(attrs.filter((o) => !o.panelHidden).length, 1, "more than one visible panel");
      const visible = attrs.find((o) => !o.panelHidden);
      const selected = attrs.find((o) => o["aria-selected"]);
      assert.equal(visible.name, selected.name, "visible panel does not belong to the selected tab");
    }
    count += seen.size;
  }
  console.log(`  states scanned: ${count}`);
});
```

```
  states scanned: 18
✔ the tab list holds a single stop via the roving tabindex (0.310542ms)
✔ in automatic mode the arrow key moves focus and selection together (0.072ms)
✔ in manual mode the arrow key only moves focus, Enter selects (0.044625ms)
✔ arrow keys wrap to the start at the ends (0.044833ms)
✔ Home and End go to the first and last tab (0.293208ms)
✔ Tab moves from the tab list to the selected panel, arrow keys do not run in the panel (0.311167ms)
✔ in every state exactly one tab is selected and only its panel is visible (0.386709ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 32.783084
```

Duration values change from run to run.

The final test verifies three invariants across all eighteen states: exactly one tab
selected, exactly one panel visible, and the visible panel belongs to the selected tab.
The third is the condition most often broken; when the selection declaration and the
visibility are updated in separate code paths, the tree can report one tab as selected
while the screen keeps a different panel open.

The sixth test checks that arrow keys do not affect the strip while inside a panel. This
is the single-character-shortcut rule's counterpart in this pattern: the tab contract's
keys are active only while the strip has focus. If a panel holds a table, the arrow keys
belong to that table.

## Keyboard Contract

| Key | Behavior |
|---|---|
| `Tab` | Brings focus to the strip; the strip is a single stop. Moves to the selected panel while in the strip. |
| `Right arrow` / `Left arrow` | Moves focus to the neighboring tab; wraps at the ends. |
| `Home` / `End` | Moves focus to the first and last tab. |
| `Enter` / `Space` | Selects the tab under focus (in manual activation mode). |

When the strip is drawn vertically, the up and down arrow keys do the same job, and the
strip carries `aria-orientation="vertical"`.

## Measurable Constraints

**4.1.2 Name, Role, Value.** Every tab's name, role, and whether it is selected must be
readable programmatically; the `aria-selected` declaration is updated every time the
selection changes.

**1.4.1 Use of Color.** The active tab cannot be distinguished by text color alone; an
underline, a fill, or a weight difference is the second channel.

**1.4.11 Non-text Contrast.** The line marking the active tab must be separated from the
neighboring surface by at least 3:1.

**2.4.7 Focus Visible.** The focus indicator must not be clipped at the strip's
overflowing edge; if the strip carries horizontal scrolling, the focused tab must be
brought into the visible area.

**2.5.8 Target Size.** Tab headings meet the 24-pixel measure; in narrow strips the
spacing between headings must stay open enough to satisfy the spacing exception.

**1.4.10 Reflow.** If the strip is left to scroll horizontally on a narrow screen, the
scrolling has to carry an indicator the user can notice; stacking the headings is also a
solution for a strip that does not overflow.

## Common Mistake

**Leaving a panel unhidden.** When panels that are not selected are hidden only in the
presentation layer — moved off-screen or made transparent — they keep standing in the
tree. The user reads all three panels' content one after another. Catching it: comparing
the number of visible panels against the number of selected tabs.

**Building tabs as links.** A tab is a button if it does not change an address. If it
does — if every section has a shareable address — the pattern is not tabs but a
navigation list, and it is declared with `aria-current`.

**Leaving the strip nameless.** Catching it is an existence rule: every element carrying
`role="tablist"` must have a name.

**Two navigation paths overlapping.** In an implementation where `Tab` advances within
the strip, the arrow keys do the same job. Catching it: counting the `tabindex` values in
the strip — if more than one element carries the value zero, the roving tabindex has not
been set up.

## Summary

- The tab pattern is used for equivalent, mutually exclusive sections; it is not used for
  sections that must be read in order or at the same time.
- The pattern has no native counterpart; the strip, heading, and panel roles are written,
  and the bond is two-way — the panel's name comes from the tab.
- The roving tabindex is mandatory: it leaves the `Tab` key a single meaning and reserves
  the arrow keys for moving within the strip.
- Automatic activation is preferred when the panel content is ready in the document;
  manual activation is chosen for content fetched from the server.
- Three invariants must hold in every state: exactly one tab selected, exactly one panel
  visible, and the visible panel belongs to the selected tab.
- The tab contract's keys are active only while the strip has focus; a table or list
  inside a panel keeps its own arrow-key behavior.

## Next Step

The tab pattern carried a constraint: only one section is visible at a time. The
"Editions," "Subject headings," and "Related records" sections at the bottom of the
record detail page do not need that constraint — the user might want to keep all three
open at once, or read the page without opening any of them. This is the unconstrained
solution to the same grouping problem, and it rests on a single state declaration: is the
section expanded right now? The next lesson specifies the accordion and shows why that
one declaration going stale is the most commonly produced defect.
