Patterns Tabs

Tabs

A row of selectors that swap a single panel of content. Keyboard model is what trips most implementations — Tab moves into and out of the tabs, arrow keys move between them.

Reviewed against the methodology checklist Updated

Try the keyboard

When to use

Tabs swap one chunk of content for another inside a single region — settings sections, dashboard views, filter buckets. Use them when:

If users need to compare two views side by side, or the content is long enough to scroll past, use sections with headings instead — tabs hide content, and hidden content is forgotten.

The pattern

Accessible tabs

Update your name, photo, and short bio. The bits people see when they hover your handle.

This is the WAI-ARIA Authoring Practices (opens in new tab) tabs pattern. Three roles, one keyboard model.

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

<div role="tabpanel" id="panel-profile"
   aria-labelledby="tab-profile" tabindex="0">

</div>
<div role="tabpanel" id="panel-notifications"
   aria-labelledby="tab-notifications" tabindex="0" hidden>…</div>
<div role="tabpanel" id="panel-billing"
   aria-labelledby="tab-billing" tabindex="0" hidden>…</div>

The bits that matter:

The keyboard model

This is what every “tabs” implementation gets wrong. The model is:

JS
tabs.forEach((tab, index) => {
tab.addEventListener('click', () => select(tab));
tab.addEventListener('keydown', (event) => {
  let target;
  if (event.key === 'ArrowRight') target = tabs[(index + 1) % tabs.length];
  else if (event.key === 'ArrowLeft') target = tabs[(index - 1 + tabs.length) % tabs.length];
  else if (event.key === 'Home') target = tabs[0];
  else if (event.key === 'End') target = tabs[tabs.length - 1];
  if (target) {
    event.preventDefault();
    select(target);
  }
});
});

function select(target) {
tabs.forEach((tab) => {
  const isSelected = tab === target;
  tab.setAttribute('aria-selected', String(isSelected));
  tab.tabIndex = isSelected ? 0 : -1;
});
panels.forEach((panel) => {
  panel.toggleAttribute('hidden', panel.id !== target.getAttribute('aria-controls'));
});
target.focus();
}

Automatic vs manual activation

The example above uses automatic activation — moving with arrow keys also selects. That’s right for cheap content (already in the DOM, no network). For panels that fetch on activation, use manual activation: arrow keys move focus only, and the user presses Enter or Space to actually select. The APG covers both — pick based on cost.

Anchor links with active styling. Looks identical, fails the keyboard model.

Don’t do this

Profile pretend-content. There’s no role here, no announcement of "tab", no arrow-key navigation.

What’s missing:

  1. No role="tab" / role="tablist" / role="tabpanel". Screen readers announce “link”, not “tab, 2 of 3, selected”. The user can’t tell this is a tab interface — they just hear three random links followed by some text.
  2. Every “tab” is in the tab order. Tab cycles through all of them before reaching the panel. With five tabs and a long page, that’s painful.
  3. Arrow keys do nothing. No keydown handler means no APG behaviour.
  4. Activation feels like navigation. Anchors update the URL hash and scroll. That’s not what tabs are supposed to do — selection should feel local.
  5. No relationship between tabs and panels. Without aria-controls / aria-labelledby, a screen reader user navigating into the panel has no idea which tab’s content they’re in.

Common variations

Checklist

Screen reader transcript

Voiced by George — reading what VoiceOver on macOS Safari announces. Other readers (NVDA, JAWS) phrase things differently; the meaning is what matters.

Screen reader announcements for each step
ListenYou doScreen reader says
Tab onto the tab listProfile, tab, selected, 1 of 3, Account settings tab list.
Press Right ArrowNotifications, tab, selected, 2 of 3.
Press Tab(focus moves into the panel content for Notifications)

Screen reader transcript — anti-pattern

Same interaction, but on the broken version above. Notice what the user is missing.

Screen reader announcements for each step
ListenYou doScreen reader says
Tab onto the first "tab"Profile, link.
Press TabNotifications, link. (every tab is in the tab order — exhausting if there are five of them)
Press Right Arrow(nothing — arrow keys do nothing because the dev didn’t add a handler)
Activate(page jumps to the in-page anchor; no role announces "tab selected")

WCAG references