Component Pattern Guide

Accessible Tabs: The WAI-ARIA Tabs Pattern Guide

Updated July 2026Reviewed by Khushwant Parihar, CPACC

Tabs are one of the most-copied and most-broken UI patterns on the web. This guide walks through the tablist, tab, and tabpanel roles, the roving tabindex keyboard model, automatic vs manual activation, and vertical tabs — with copy-ready HTML, JavaScript, and React mapped to WCAG 2.2.

Why the Tabs Pattern Is So Easy to Get Wrong

A tab set looks trivial: a row of buttons, one panel visible at a time. But native HTML has no <tabs> element, so every tab component is a custom widget assembled from <div>s, ARIA roles, and JavaScript. Miss any one piece and the pattern silently degrades: a screen reader announces four unlabeled buttons instead of “tab, 1 of 4, selected,” or a keyboard user has to press Tab through every tab because the Arrow keys do nothing.

The good news is that the pattern is fully specified. The WAI-ARIA Authoring Practices Tabs pattern defines exactly which roles, states, and key bindings a conformant tab set needs. This guide turns that specification into working code you can paste in, and ties every requirement back to the WCAG success criterion it satisfies.

Use tabs only for same-page panels.If your “tabs” navigate to different URLs, they are navigation — use a <nav> with real <a href> links, not role="tab". If sections should expand independently or several be open at once, use an accordion. Tabs are for switching between peer panels where exactly one is visible at a time.

The WCAG 2.2 Criteria Accessible Tabs Satisfy

WCAG 2.2 success criteria that a correctly built tab set satisfies and what each requires
CriterionLevelWhat the tabs pattern must do
1.3.1 Info & RelationshipsATab and panel are programmatically linked (aria-controls / aria-labelledby).
2.1.1 KeyboardAEvery tab is reachable and operable with Arrow, Home, End, and Enter/Space.
2.4.3 Focus OrderAThe tab list is a single Tab stop; focus flows logically to the panel.
2.4.7 Focus VisibleAAThe focused tab shows a clear, visible focus indicator.
3.2.1 On FocusAWith manual activation, focusing a tab does not itself change context.
4.1.2 Name, Role, ValueACorrect tab/tablist/tabpanel roles and the aria-selected state are exposed.

See the full picture in the WCAG 2.2 Level AA requirements and work through the interactive WCAG 2.2 checklist.

1. The Three Roles: tablist, tab & tabpanel

An accessible tab set is built from three ARIA roles plus the states and relationships that connect them. Get this table right and the rest is keyboard behavior. Each tab points at the panel it controls with aria-controls; each panel points back at its tab with aria-labelledbyso the panel inherits the tab's label.

The three roles in the tabs pattern and the ARIA attributes each element needs
ElementRoleKey attributes
Tab list containerrole="tablist"aria-label or aria-labelledby (name the set); optional aria-orientation="vertical"
Each tabrole="tab"id, aria-selected="true|false", aria-controls="<panel-id>", tabindex="0" if selected else tabindex="-1"
Each tab panelrole="tabpanel"id, aria-labelledby="<tab-id>", tabindex="0" when it has no focusable content; hidden when inactive

Prefer a real <button> for each tab rather than a <div> with role="tab". A button is already keyboard operable and announced as interactive, so you inherit correct behavior and only layer the tab semantics on top. For the underlying meaning of each role and state, see the ARIA roles & attributes reference.

2. The Complete Accessible Markup

Here is a full, conformant tab set in plain HTML. Note the roving tabindex (only the selected tab is 0), the hidden attribute on inactive panels, and the paired aria-controls / aria-labelledby ids.

<!-- Name the whole set for screen readers -->
<div class="tabs">
  <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-billing"
            aria-selected="false" aria-controls="panel-billing"
            tabindex="-1">
      Billing
    </button>
    <button role="tab" id="tab-security"
            aria-selected="false" aria-controls="panel-security"
            tabindex="-1">
      Security
    </button>
  </div>

  <!-- Static-content panels get tabindex="0" so they are focusable -->
  <div role="tabpanel" id="panel-profile"
       aria-labelledby="tab-profile" tabindex="0">
    <h3>Profile</h3>
    <p>Your public profile information.</p>
  </div>
  <div role="tabpanel" id="panel-billing"
       aria-labelledby="tab-billing" tabindex="0" hidden>
    <h3>Billing</h3>
    <p>Manage your subscription and invoices.</p>
  </div>
  <div role="tabpanel" id="panel-security"
       aria-labelledby="tab-security" tabindex="0" hidden>
    <h3>Security</h3>
    <p>Password and two-factor settings.</p>
  </div>
</div>

Two details do a lot of work here. First, aria-label on the tablistnames the set so a screen reader can say “Account settings, tab list.” Second, the hidden attribute — not just CSS opacity — removes inactive panels from the accessibility tree so users never hear all three panels stacked together. Hide inactive panels deliberately; see the focus management guide for why visibility and the accessibility tree must stay in sync.

3. The Keyboard Interaction Model

This is where most tab components fail. A tab list must be a single Tab stop: you Tab onto the active tab, then the Arrow keys move between tabs, and Tab again leaves the list. That is the roving tabindex technique — exactly one tab is tabindex="0" and the rest are tabindex="-1".

Keyboard keys and the action each performs in the tabs pattern
KeyAction
TabMoves focus into the tab list onto the active tab; pressing it again moves focus out of the list to the active panel or next control.
Right Arrow / Left ArrowIn a horizontal tab list, moves focus to the next / previous tab, wrapping from last to first and back.
Down Arrow / Up ArrowIn a vertical tab list (aria-orientation="vertical"), moves focus to the next / previous tab, wrapping.
HomeMoves focus to the first tab in the list.
EndMoves focus to the last tab in the list.
Enter or SpaceActivates the focused tab (required only for manual activation; automatic activation selects on focus).

Wrapping is recommended but optional: Right Arrow on the last tab may move to the first, and Left Arrow on the first may move to the last. The single-Tab-stop rule is not optional — it is what makes a tab set feel native and satisfies 2.4.3 Focus Order. For the broader technique across menus and toolbars, see roving tabindex in the keyboard accessibility guide.

4. Automatic vs Manual Activation

There are two conformant ways a tab becomes selected, and choosing between them is a UX decision, not an accessibility one. Both are allowed by the APG; the deciding question is how expensive it is to show a panel.

Automatic activation

The panel changes the instant a tab receives focus — one Arrow press both moves focus and selects. Fastest for everyone.

Use when switching is instant and side-effect free — the panels are already rendered in the DOM.

Manual activation

Arrow keys move focus only; the user presses Enter or Space to select and reveal the panel.

Use when selecting a tab is expensive — it fetches data, moves focus, or would be disruptive on every arrow press.

Manual activation also keeps you safely inside 3.2.1 On Focus, because merely moving focus to a tab never triggers a change of context. When in doubt for a data-heavy interface, prefer manual.

5. Wiring It Up in Vanilla JavaScript

This handler implements the full keyboard model with roving tabindex and automatic activation. Swap to manual by only moving focus on Arrow keys and selecting on Enter/Space.

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

function selectTab(newTab) {
  tabs.forEach((tab) => {
    const selected = tab === newTab
    tab.setAttribute("aria-selected", String(selected))
    tab.tabIndex = selected ? 0 : -1               // roving tabindex
    // Show the matching panel, hide the rest
    const panel = document.getElementById(tab.getAttribute("aria-controls"))
    panel.hidden = !selected
  })
}

tablist.addEventListener("keydown", (e) => {
  const current = tabs.indexOf(document.activeElement)
  if (current === -1) return
  let next = null

  switch (e.key) {
    case "ArrowRight": next = (current + 1) % tabs.length; break
    case "ArrowLeft":  next = (current - 1 + tabs.length) % tabs.length; break
    case "Home":       next = 0; break
    case "End":        next = tabs.length - 1; break
    default: return
  }
  e.preventDefault()
  tabs[next].focus()          // move DOM focus with the Arrow key
  selectTab(tabs[next])       // automatic activation — remove for manual
})

// Pointer users still click
tabs.forEach((tab) =>
  tab.addEventListener("click", () => { tab.focus(); selectTab(tab) })
)

Notice that the same selectTab() function drives clicks, Arrow keys, Home, and End — one source of truth for aria-selected, tabindex, and panel visibility keeps the three from drifting out of sync, which is the usual cause of a screen reader announcing the wrong selected tab.

6. Vertical Tabs & aria-orientation

When a tab list is stacked vertically, tell assistive technology so it maps the Up/Down arrows to navigation instead of Left/Right. Add aria-orientation="vertical" to the tablist and switch your key handler to ArrowUp / ArrowDown.

<div role="tablist"
     aria-label="Documentation sections"
     aria-orientation="vertical">
  <button role="tab" aria-selected="true"  tabindex="0"  ...>Getting started</button>
  <button role="tab" aria-selected="false" tabindex="-1" ...>API reference</button>
  <button role="tab" aria-selected="false" tabindex="-1" ...>Examples</button>
</div>

<!-- In the keydown handler, use ArrowUp / ArrowDown for vertical lists -->
case "ArrowDown": next = (current + 1) % tabs.length; break
case "ArrowUp":   next = (current - 1 + tabs.length) % tabs.length; break

Horizontal is the default, so you only add aria-orientation for vertical lists. The visual orientation and the declared orientation must match — a vertical strip that still listens for Left/Right arrows will confuse anyone using a screen reader that announces the orientation.

7. Accessible Tabs in React

The roles, states, and keyboard model are identical — React just derives them from state. Use the useId hook for stable aria-controls / aria-labelledby ids, and refs to move focus on Arrow keys.

function Tabs({ tabs }) {
  const [active, setActive] = React.useState(0)
  const uid = React.useId()
  const refs = React.useRef([])

  function onKeyDown(e) {
    const last = tabs.length - 1
    let next = null
    if (e.key === "ArrowRight") next = active === last ? 0 : active + 1
    else if (e.key === "ArrowLeft") next = active === 0 ? last : active - 1
    else if (e.key === "Home") next = 0
    else if (e.key === "End") next = last
    else return
    e.preventDefault()
    setActive(next)             // automatic activation
    refs.current[next]?.focus() // move focus with the arrow key
  }

  return (
    <div>
      <div role="tablist" aria-label="Settings" onKeyDown={onKeyDown}>
        {tabs.map((tab, i) => (
          <button
            key={i}
            ref={(el) => (refs.current[i] = el)}
            role="tab"
            id={`${uid}-tab-${i}`}
            aria-selected={active === i}
            aria-controls={`${uid}-panel-${i}`}
            tabIndex={active === i ? 0 : -1}
            onClick={() => setActive(i)}
          >
            {tab.label}
          </button>
        ))}
      </div>
      {tabs.map((tab, i) => (
        <div
          key={i}
          role="tabpanel"
          id={`${uid}-panel-${i}`}
          aria-labelledby={`${uid}-tab-${i}`}
          tabIndex={0}
          hidden={active !== i}
        >
          {tab.content}
        </div>
      ))}
    </div>
  )
}

In production, reach for a headless, WAI-ARIA-tested library — Radix UI Tabs, React Aria, or Headless UI — which ships the full keyboard model, roving tabindex, and orientation handling for you. The React accessibility guide covers the useId and ref patterns in depth, and the same approach applies in the Vue and Angular accessibility guides.

How to Test Accessible Tabs

Automated scanners confirm the roles are present, but they cannot verify that the keyboard model works. Run this manual pass on every tab set:

  1. Tab into the list once. Focus should land on the selected tab — not the first tab, and not every tab in turn.
  2. Press the Arrow keys. Focus moves between tabs and (in automatic mode) the panel switches; Home and End jump to the ends.
  3. Press Tab again. Focus leaves the tab list and lands in the active panel or its first focusable control — never a hidden panel.
  4. Listen with a screen reader. You should hear the tab's name, “tab,” its position (“2 of 4”), and “selected” on the active one. Verify with the screen reader testing guide.
  5. Confirm hidden panels are gone. Inactive panel content should not be reachable by the virtual cursor or the Tab key.

Layer automated checks on top: axe-core flags missing or mismatched aria-controls targets and invalid role nesting. See automated vs manual testing for where each fits, then scan the live page with the URL accessibility auditor.

Common Tab Mistakes & How to Fix Them

Common accessible-tabs anti-patterns, why they fail, and the fix
Anti-patternWhy it failsThe fix
Using <div>s with click handlers and no roles.A screen reader announces nothing — no tab, no count, no selected state (4.1.2).Add role="tablist"/"tab"/"tabpanel" with aria-selected and aria-controls.
Every tab is in the Tab order (all tabindex="0").Keyboard users must Tab through every tab; Arrow keys do nothing (2.1.1, 2.4.3).Roving tabindex: only the selected tab is 0, the rest are -1; wire Arrow keys.
aria-selected never changes when the tab switches.The screen reader keeps announcing the wrong tab as selected (4.1.2).Set aria-selected="true" on the active tab and "false" on all others on every change.
Inactive panels are hidden with CSS opacity or off-screen only.They stay in the accessibility tree, so screen reader users hear all panels at once.Add the hidden attribute (or display:none) to inactive panels so they leave the tree.
Tabs that actually change the URL / navigate.role="tab" misrepresents links as tabs; breaks Back button and SR expectations.Use a <nav> with real <a href> links, not the tabs pattern.
No aria-controls / aria-labelledby linking tab and panel.The relationship is lost; users can't tell which panel belongs to which tab (1.3.1).Point each tab at its panel with aria-controls and each panel back with aria-labelledby.

Accessible Tabs Checklist

  1. Right pattern. Content is same-page peer panels — not links (use <nav>) or independently expandable sections (use an accordion).
  2. Three roles. role="tablist" wraps role="tab" buttons; each panel is role="tabpanel".
  3. Named & linked. The list has aria-label; tabs use aria-controls and panels use aria-labelledby.
  4. State is live. Exactly one tab has aria-selected="true", updated on every switch.
  5. Single Tab stop. Roving tabindex — selected tab is 0, the rest -1; Arrow, Home, End wired.
  6. Panels hidden properly. Inactive panels use the hidden attribute so they leave the accessibility tree.
  7. Focus visible. The focused tab shows a clear indicator (2.4.7).

Work through the full WCAG 2.2 checklist to see tabs in the context of every other requirement.

Check Your Tabs on a Live Page

Scan any page with our free axe-core-powered auditor to catch missing roles, broken aria-controls targets, and unlabeled controls — then run the manual keyboard pass above.

Frequently Asked Questions

What ARIA roles do accessible tabs need?

An accessible tab set uses exactly three roles that work together. The container that holds the clickable tabs has role="tablist". Each individual tab has role="tab", and each content region has role="tabpanel". These roles tell a screen reader "this is a tab set," so it can announce "tab, 2 of 4, selected" and expose the correct keyboard model. On top of the roles you need the relationships and state: each tab points at its panel with aria-controls, each panel points back at its tab with aria-labelledby, the active tab carries aria-selected="true" (and the rest aria-selected="false"), and inactive panels are hidden. Roles without those states are only half the pattern — the screen reader would announce a tab but never say which one is selected.

How should keyboard navigation work in a tab set?

The tab list is a single Tab stop. You press Tab once to move focus onto the active tab, then use the Arrow keys — Left/Right for horizontal tabs, Up/Down for vertical — to move between tabs. Home jumps to the first tab and End to the last. Pressing Tab again moves focus out of the tab list and into the active tab panel. This is the roving tabindex model: only one tab is in the Tab sequence at a time (tabindex="0"), and the others are removed from it (tabindex="-1") but reachable with the Arrow keys. The most common bug is making every tab tabbable, which forces keyboard users to press Tab through all of them and breaks the expected Arrow-key behavior.

What is the difference between automatic and manual tab activation?

With automatic activation, a tab is selected the moment it receives focus — pressing the Arrow key both moves focus and shows the associated panel. With manual activation, the Arrow keys only move focus; the user then presses Enter or Space to actually select the tab and reveal its panel. The rule from the ARIA Authoring Practices Guide is about cost: use automatic activation when showing a panel is instant and has no side effects, because it is faster for everyone. Use manual activation when selecting a tab is expensive — it loads data over the network, moves focus, or would be disruptive to trigger on every arrow press. Both are conformant; the choice is a UX decision, not an accessibility one.

Should the tab panel be focusable with tabindex="0"?

It depends on what the panel contains. If a panel holds its own focusable content — links, form fields, buttons — you do not need to make the panel itself focusable; the user simply Tabs from the tab list into that content. If a panel is static text with nothing focusable inside, add tabindex="0" to the panel so keyboard users can move focus into it, read it, and scroll it. Without that, a keyboard user pressing Tab would skip straight past the panel to the next control on the page, and someone relying on a screen reader's Tab key could miss the panel content. When in doubt, adding tabindex="0" to the panel is the safe default recommended by the APG.

Are tabs the same as in-page navigation links or an accordion?

No, and confusing them is a frequent mistake. The tabs pattern is for switching between panels of related content that live on the same page and share a heading area — like "Description / Reviews / Shipping" on a product page. If your "tabs" actually navigate to different URLs, they are navigation and should be a <nav> with real links, not role="tab"; using the tab roles there misrepresents them to screen readers. An accordion is the better choice when content should be able to expand independently, when several sections may be open at once, or on narrow screens where a horizontal tab strip does not fit. Reach for tabs only when exactly one panel is visible at a time and the sections are peers.

How do I make tabs accessible in React?

The roles, states, and keyboard model are identical to plain HTML — React just manages them with state. Track the active index in state, render role="tablist"/"tab"/"tabpanel", set aria-selected and tabindex from whether each tab is active, generate stable ids with the useId hook to wire aria-controls and aria-labelledby, and handle onKeyDown for the Arrow, Home, and End keys, calling .focus() on the newly active tab via refs. The most reliable path in production is to use a headless, WAI-ARIA-tested library — Radix UI Tabs, React Aria Tabs, Headless UI, or Reach — which implements the full keyboard model and roving tabindex for you. Our React accessibility guide covers the ref and useId patterns you need if you build it by hand.

Essential Accessibility Resources

Comprehensive tools, checklists, and guides to help you create inclusive digital experiences

Top Pick

Complete Keyboard Accessibility Guide

Definitive guide to keyboard accessibility with interactive demos, code examples, and testing checklists
keyboard accessibility
focus management
keyboard navigation
+2 more
View guide
Top Pick

Focus Management Guide

tabindex, :focus-visible, focus traps, restoration, roving tabindex, skip links, and route-change focus, mapped to WCAG 2.2
focus management
tabindex
roving tabindex
+1 more
View guide
Top Pick

WCAG 4.1.2 Name, Role, Value Guide

Complete guide to name, role, state, and value for UI components: accessible name computation, native HTML vs ARIA, code examples, common failures, and testing
wcag 4.1.2
aria role
screen reader
View guide
Top Pick

WCAG 2.4.7 Focus Visible Guide

Complete guide to visible keyboard focus indicators: why never to remove the outline, :focus-visible, contrast and thickness, forced-colors support, code examples, and testing
keyboard accessibility
focus management
View guide
Top Pick

WCAG 2.4.11 Focus Not Obscured (Minimum) Guide

Complete guide to keeping the keyboard-focused element visible: why sticky headers hide focus, the scroll-padding and scroll-margin fix, code examples, and testing
focus management
keyboard accessibility
View guide
Top Pick

React Accessibility Guide

Semantic JSX, focus management, accessible modals, ARIA in JSX, live regions, forms with useId, and testing with jest-axe
react accessibility
react aria
react focus management
View guide