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-controlsandaria-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
hiddenattribute and are not reachable by Tab. - Run axe-core — check for
aria-required-children(tablist must contain tabs) andaria-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).
