#604 Accessible Tabs

medium
javascript
accessibility
dom

A tabs widget looks simple, but a row of buttons that toggles some divs is invisible to screen reader users and awkward for keyboard users. The WAI-ARIA tabs pattern describes how it should behave.

Write a function createTabs(root) that takes the markup below and turns it into an accessible tabs widget.

<div id="settings">
  <div data-tablist aria-label="Settings">
    <button data-tab="profile">Profile</button>
    <button data-tab="billing">Billing</button>
    <button data-tab="team" disabled>Team</button>
    <button data-tab="alerts">Alerts</button>
  </div>
  <div data-panel="profile">Your name and avatar</div>
  <div data-panel="billing">Your plan and invoices</div>
  <div data-panel="team">Invite people</div>
  <div data-panel="alerts">Email and push settings</div>
</div>

A tab and its panel share the same key (data-tab="billing" goes with data-panel="billing").

Roles and relationships

  • [data-tablist] gets role="tablist".
  • Every [data-tab] gets role="tab" and aria-controls pointing at its panel's id.
  • Every [data-panel] gets role="tabpanel", tabindex="0" and aria-labelledby pointing at its tab's id.
  • If a tab or panel has no id, give it <key>-tab or <key>-panel. Keep ids that already exist.

Selection

  • Exactly one tab is selected. It has aria-selected="true" and tabindex="0". Every other tab has aria-selected="false" and tabindex="-1", so Tab lands only on the selected tab.
  • Only the selected tab's panel is visible. Hide the others with the hidden attribute.
  • On start, select the tab that already has aria-selected="true" in the markup. If none does, select the first enabled tab.
  • Clicking an enabled tab selects it.

Keyboard (when a tab has focus)

  • ArrowRight / ArrowLeft move focus to the next / previous tab and select it, wrapping around at the ends.
  • Home / End move to the first / last tab.
  • Disabled tabs are skipped.
  • Call preventDefault() for the keys you handle, and leave every other key alone.

createTabs returns a function that removes every listener it added.

const destroy = createTabs(document.getElementById("settings"));

// "Profile" is selected, its panel is visible.
// Focus "Billing" and press ArrowRight: focus jumps over "Team" to "Alerts".
// Press ArrowRight again: focus wraps back to "Profile".

destroy(); // clicks and key presses do nothing now