Accessible tabs

Live demo

Why tabs help ADHD and neurodivergent users

Tabs organize complex content into discrete categories, letting users focus on one section at a time. For ADHD users, this reduces the temptation to context-switch by making unrelated content invisible. For autistic users who prefer predictable structure, a consistent tab pattern provides clear wayfinding cues.

The challenge is that the tab pattern has the most complex keyboard interaction model in the ARIA specification — roving tabindex, arrow key navigation within the tablist, and Tab to enter the panel. Getting it wrong breaks the experience for screen reader and keyboard users.

Required WCAG success criteria

  • SC 4.1.2 Name, Role, Value (A) — each tab must expose its role (role="tab"), its label, and its selected state (aria-selected="true/false") to assistive technology.
  • SC 2.1.1 Keyboard (A) — all tabs must be reachable and operable without a mouse. Arrow keys navigate within the tablist; Tab moves focus into the active panel.
  • SC 2.4.7 Focus Visible (AA) — the currently focused tab must have a visible focus indicator. The selected tab and the focused tab are separate states — both must be visually distinct.
  • SC 1.3.1 Info and Relationships (A) — the relationship between each tab and its panel must be programmatic, not just visual, via aria-controls and aria-labelledby.

The correct ARIA pattern

Three roles work together: tablist wraps all tabs, tab is each individual tab button, and tabpanel is the associated content area.

<div role="tablist" aria-label="Account settings">
  <button
    role="tab"
    id="tab-profile"
    aria-selected="true"
    aria-controls="panel-profile"
    tabindex="0"
  >
    Profile
  </button>
  <button
    role="tab"
    id="tab-security"
    aria-selected="false"
    aria-controls="panel-security"
    tabindex="-1"
  >
    Security
  </button>
  <button
    role="tab"
    id="tab-notifications"
    aria-selected="false"
    aria-controls="panel-notifications"
    tabindex="-1"
  >
    Notifications
  </button>
</div>

<div
  role="tabpanel"
  id="panel-profile"
  aria-labelledby="tab-profile"
>
  <p>Update your name, email, and avatar.</p>
</div>

<div
  role="tabpanel"
  id="panel-security"
  aria-labelledby="tab-security"
  hidden
>
  <p>Change your password and enable two-factor authentication.</p>
</div>

<div
  role="tabpanel"
  id="panel-notifications"
  aria-labelledby="tab-notifications"
  hidden
>
  <p>Choose which emails and alerts you receive.</p>
</div>

Keyboard interaction — roving tabindex

Tab panels use a roving tabindex pattern. Only the active tab has tabindex="0"; all others have tabindex="-1". This means a single Tab keypress enters the tablist, and arrow keys then move between tabs — rather than requiring many Tab presses to reach a tab deep in the list.

  • Left / Right arrow — move focus between tabs within the tablist (wrap at ends).
  • Home — move focus to the first tab.
  • End — move focus to the last tab.
  • Tab — move focus out of the tablist into the active panel (or next focusable element after the tabs).
  • Enter / Space — activate the focused tab (required for manual activation mode; automatic mode activates on arrow key).

Automatic vs manual activation

Automatic activation means the panel switches as soon as the user arrows to a tab — no Enter/Space required. This is better for screen reader users because they hear the panel content immediately without a second keypress. Use it when panels load instantly from existing DOM content.

Manual activation means the user must press Enter or Space after arrowing to a tab. This is better when panel content loads from the network — automatic activation would fire a network request on every arrow keypress, which is wasteful and slow for users on low-bandwidth connections. The WCAG APG recommends manual activation whenever content is fetched asynchronously.

Roving tabindex JavaScript

const tablist = document.querySelector('[role="tablist"]');
const tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));

tablist.addEventListener('keydown', (e) => {
  const current = tabs.indexOf(document.activeElement);
  let next = -1;

  if (e.key === 'ArrowRight') next = (current + 1) % tabs.length;
  if (e.key === 'ArrowLeft')  next = (current - 1 + tabs.length) % tabs.length;
  if (e.key === 'Home')       next = 0;
  if (e.key === 'End')        next = tabs.length - 1;

  if (next === -1) return;
  e.preventDefault();

  // Update roving tabindex
  tabs[current].setAttribute('tabindex', '-1');
  tabs[next].setAttribute('tabindex', '0');
  tabs[next].focus();

  // Automatic activation — remove this block for manual activation
  activateTab(tabs[next]);
});

tabs.forEach((tab) => {
  tab.addEventListener('click', () => activateTab(tab));
});

function activateTab(tab) {
  tabs.forEach((t) => {
    t.setAttribute('aria-selected', 'false');
    t.setAttribute('tabindex', '-1');
    document.getElementById(t.getAttribute('aria-controls'))
      .setAttribute('hidden', '');
  });

  tab.setAttribute('aria-selected', 'true');
  tab.setAttribute('tabindex', '0');
  document.getElementById(tab.getAttribute('aria-controls'))
    .removeAttribute('hidden');
}

Testing checklist

  • Tab into the tablist — confirm only one tab receives focus (the selected one). Arrow through all tabs — confirm focus moves and the selected panel switches.
  • Press Tab from the last tab — confirm focus moves into the active panel or the next focusable element, not to a hidden panel.
  • With VoiceOver (Safari) and NVDA (Firefox) — confirm each tab announces its role ("tab"), its label, and its selected state ("selected" / "not selected").
  • Confirm hidden panels have the hidden attribute and are not reachable by Tab.
  • Run axe-core — check for aria-required-children (tablist must contain tabs) and aria-required-parent (tabs must be inside tablist).
  • Zoom to 400% — confirm tabs wrap cleanly and do not overflow the viewport without a scroll mechanism (SC 1.4.10 Reflow).