Accessible Tabs: The WAI-ARIA Tabs Pattern Guide
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
| Criterion | Level | What the tabs pattern must do |
|---|---|---|
| 1.3.1 Info & Relationships | A | Tab and panel are programmatically linked (aria-controls / aria-labelledby). |
| 2.1.1 Keyboard | A | Every tab is reachable and operable with Arrow, Home, End, and Enter/Space. |
| 2.4.3 Focus Order | A | The tab list is a single Tab stop; focus flows logically to the panel. |
| 2.4.7 Focus Visible | AA | The focused tab shows a clear, visible focus indicator. |
| 3.2.1 On Focus | A | With manual activation, focusing a tab does not itself change context. |
| 4.1.2 Name, Role, Value | A | Correct 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.
| Element | Role | Key attributes |
|---|---|---|
| Tab list container | role="tablist" | aria-label or aria-labelledby (name the set); optional aria-orientation="vertical" |
| Each tab | role="tab" | id, aria-selected="true|false", aria-controls="<panel-id>", tabindex="0" if selected else tabindex="-1" |
| Each tab panel | role="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".
| Key | Action |
|---|---|
| Tab | Moves 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 Arrow | In a horizontal tab list, moves focus to the next / previous tab, wrapping from last to first and back. |
| Down Arrow / Up Arrow | In a vertical tab list (aria-orientation="vertical"), moves focus to the next / previous tab, wrapping. |
| Home | Moves focus to the first tab in the list. |
| End | Moves focus to the last tab in the list. |
| Enter or Space | Activates 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; breakHorizontal 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:
- Tab into the list once. Focus should land on the selected tab — not the first tab, and not every tab in turn.
- Press the Arrow keys. Focus moves between tabs and (in automatic mode) the panel switches; Home and End jump to the ends.
- Press Tab again. Focus leaves the tab list and lands in the active panel or its first focusable control — never a hidden panel.
- 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.
- 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
| Anti-pattern | Why it fails | The 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
- Right pattern. Content is same-page peer panels — not links (use
<nav>) or independently expandable sections (use an accordion). - Three roles.
role="tablist"wrapsrole="tab"buttons; each panel isrole="tabpanel". - Named & linked. The list has
aria-label; tabs usearia-controlsand panels usearia-labelledby. - State is live. Exactly one tab has
aria-selected="true", updated on every switch. - Single Tab stop. Roving
tabindex— selected tab is0, the rest-1; Arrow, Home, End wired. - Panels hidden properly. Inactive panels use the
hiddenattribute so they leave the accessibility tree. - 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