// pattern
Accessible Tabs Pattern
A tabbed interface with proper role="tablist", arrow key navigation, roving tabindex, and automatic panel association. This is one of the few patterns that genuinely requires ARIA — there is no native HTML tab element.
// 01 · live demo
Live Demo
Use Arrow Left/Arrow Right to switch tabs. Press Tab to move into the active panel.
Perceivable
Information and user interface components must be presentable to users in ways they can perceive. This means content can't be invisible to all of a user's senses.
- Provide text alternatives for non-text content
- Provide captions and alternatives for multimedia
- Create content that can be presented in different ways without losing meaning
- Make it easier for users to see and hear content
Operable
User interface components and navigation must be operable. Users must be able to interact with the interface — it cannot require interaction that a user cannot perform.
- Make all functionality available from a keyboard
- Give users enough time to read and use content
- Do not use content that causes seizures or physical reactions
- Help users navigate, find content, and determine where they are
Understandable
Information and the operation of the user interface must be understandable. Users must be able to comprehend the information and how to operate the interface.
- Make text readable and understandable
- Make content appear and operate in predictable ways
- Help users avoid and correct mistakes
- Ensure consistent navigation and identification
Robust
Content must be robust enough that it can be interpreted by a wide variety of user agents, including assistive technologies. As technologies evolve, the content should remain accessible.
- Maximize compatibility with current and future user agents
- Use valid, well-formed markup
- Ensure name, role, and value are programmatically determinable
- Provide status messages that can be announced by screen readers
Inaccessible version (div-based):
This tab interface uses plain <span> elements. No keyboard navigation, no ARIA roles, no screen reader announcements. Click works, but nothing else does.
No way to reach this panel with a keyboard. No role, no state, no relationship to the tab.
Screen readers see no tab structure here — just text and more text.
// 02 · the code
The Code
<!-- Tab container -->
<div class="tabs">
<!-- Tab list: role="tablist" groups the tabs -->
<div role="tablist" aria-label="Web accessibility topics">
<button role="tab"
aria-selected="true"
aria-controls="panel-1"
id="tab-1"
tabindex="0">
First Tab
</button>
<button role="tab"
aria-selected="false"
aria-controls="panel-2"
id="tab-2"
tabindex="-1">
Second Tab
</button>
<button role="tab"
aria-selected="false"
aria-controls="panel-3"
id="tab-3"
tabindex="-1">
Third Tab
</button>
</div>
<!-- Panels: each linked to its tab via
aria-labelledby and the tab's aria-controls -->
<div role="tabpanel"
id="panel-1"
aria-labelledby="tab-1"
tabindex="0">
<h3>First Panel</h3>
<p>Content for the first tab.</p>
</div>
<div role="tabpanel"
id="panel-2"
aria-labelledby="tab-2"
tabindex="0"
hidden>
<h3>Second Panel</h3>
<p>Content for the second tab.</p>
</div>
<div role="tabpanel"
id="panel-3"
aria-labelledby="tab-3"
tabindex="0"
hidden>
<h3>Third Panel</h3>
<p>Content for the third tab.</p>
</div>
</div>
[role="tablist"] {
display: flex;
border-bottom: 2px solid #d1d5db;
gap: 0.25rem;
}
[role="tab"] {
padding: 0.5rem 1rem;
border: none;
border-bottom: 3px solid transparent;
background: none;
color: #6b7280;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
margin-bottom: -2px;
/* SC 2.5.8: minimum 24x24 target */
min-height: 2.75rem;
}
[role="tab"]:hover {
color: #1f2937;
}
[role="tab"][aria-selected="true"] {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
/* SC 2.4.13: focus indicator with 3:1 contrast */
[role="tab"]:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
border-radius: 0.25rem;
}
[role="tabpanel"] {
padding: 1.5rem;
}
[role="tabpanel"]:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
border-radius: 0.25rem;
}
[role="tabpanel"][hidden] {
display: none;
}
/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
[role="tab"] {
transition: none;
}
}
function initTabs(tabContainer) {
const tablist = tabContainer.querySelector('[role="tablist"]');
const tabs = tablist.querySelectorAll('[role="tab"]');
const panels = tabContainer.querySelectorAll('[role="tabpanel"]');
function switchTab(newTab) {
// Deactivate all tabs
tabs.forEach(tab => {
tab.setAttribute('aria-selected', 'false');
tab.setAttribute('tabindex', '-1');
});
// Activate the new tab
newTab.setAttribute('aria-selected', 'true');
newTab.setAttribute('tabindex', '0');
newTab.focus();
// Show the correct panel, hide the rest
panels.forEach(panel => {
panel.hidden = true;
});
const newPanel = tabContainer.querySelector(
'#' + newTab.getAttribute('aria-controls')
);
newPanel.hidden = false;
}
tablist.addEventListener('keydown', (e) => {
const currentIndex = Array.from(tabs).indexOf(
document.activeElement
);
let newIndex;
switch (e.key) {
case 'ArrowRight':
// Next tab, wraps to first
newIndex = (currentIndex + 1) % tabs.length;
break;
case 'ArrowLeft':
// Previous tab, wraps to last
newIndex = (currentIndex - 1 + tabs.length)
% tabs.length;
break;
case 'Home':
newIndex = 0;
break;
case 'End':
newIndex = tabs.length - 1;
break;
default:
return; // Don't prevent other keys
}
e.preventDefault();
switchTab(tabs[newIndex]);
});
// Click handler for each tab
tabs.forEach(tab => {
tab.addEventListener('click', () => switchTab(tab));
});
}
// Initialize all tab containers on the page
document.querySelectorAll('.tabs').forEach(initTabs);
<button> elements give you keyboard activation (Enter/Space) and focusability for free. Using <div> or <span> would require adding tabindex and keydown listeners manually — unnecessary work when buttons handle it natively.
// 03 · why these decisions
Why These Decisions
Why ARIA is genuinely required here
Most patterns on this site use native HTML elements that provide built-in accessibility. Tabs are different. HTML has no <tabs>, <tab>, or <tabpanel> elements. Without ARIA roles, a screen reader sees a row of buttons and some content divs — it has no way to know these form a tabbed interface, which tab is selected, or which panel belongs to which tab. The role="tablist", role="tab", and role="tabpanel" roles are the only way to communicate this structure to assistive technology.
Why roving tabindex instead of tabindex="0" on all tabs?
Roving tabindex means only the active tab has tabindex="0", while all other tabs have tabindex="-1". This creates the expected keyboard interaction: pressing Tab moves focus into the tablist (landing on the active tab), then pressing Tab again moves focus into the panel content. If all tabs had tabindex="0", the user would have to tab through every single tab before reaching the panel — a poor experience with many tabs.
Why arrow keys for tab navigation?
Arrow keys are the expected convention for moving between tabs, as defined by the WAI-ARIA Authoring Practices. This matches how tabs work in desktop operating systems and other ARIA widget patterns (like toolbars and menubars). Screen reader users know to use arrow keys within composite widgets. Using Tab to move between tabs would break the expected pattern where Tab moves focus to the next component, not within the current one.
Why wrap arrow navigation at the ends?
When the user presses Arrow Right on the last tab, focus wraps to the first tab (and vice versa). This is a standard convention for tab widgets and prevents the user from getting "stuck" at either end. It also makes navigation faster since users can always reach any tab in at most half the total number of arrow presses.
Why aria-controls and aria-labelledby?
These two attributes create a bidirectional link between tabs and panels. aria-controls on a tab tells assistive technology which panel it controls. aria-labelledby on a panel tells assistive technology which tab labels it. Some screen readers use aria-controls to let users jump directly from a tab to its panel (e.g., JAWS with Ctrl+Alt+M). aria-labelledby ensures the panel announces its associated tab name when focused.
Why tabindex="0" on panels?
Tab panels need to be focusable so keyboard users can tab into the panel content after selecting a tab. Without tabindex="0", pressing Tab from the tablist would skip over the panel entirely and jump to whatever comes after the tabs component. The focus indicator on the panel also gives sighted keyboard users a clear visual cue of where they are.
// 04 · keyboard interaction
Keyboard Interaction
| Key | Action |
|---|---|
| Tab | Moves focus into the tablist (on the active tab), then into the active panel, then out of the tabs component |
| Shift + Tab | Moves focus backward through the same sequence: panel, active tab, previous component |
| Arrow Right | Moves focus to the next tab and activates it. Wraps from last tab to first. |
| Arrow Left | Moves focus to the previous tab and activates it. Wraps from first tab to last. |
| Home | Moves focus to the first tab and activates it |
| End | Moves focus to the last tab and activates it |
| Enter / Space | Activates the focused tab (handled by native <button> — no extra JS needed) |
// 05 · wcag 2.2 success criteria
WCAG 2.2 Success Criteria
This pattern satisfies the following WCAG 2.2 success criteria:
- 2.1.1 Keyboard Level A — All tab functionality is operable via keyboard. Arrow keys navigate between tabs, Tab moves into panels, and Home/End jump to first/last tabs.
- 2.1.2 No Keyboard Trap Level A — Users can always tab out of the tab component. Focus moves naturally from tablist to panel to the next page element.
-
1.3.1 Info and Relationships
Level A
— ARIA roles (
tablist,tab,tabpanel) convey the tabbed interface structure.aria-controlsandaria-labelledbyestablish relationships between tabs and their panels. - 2.4.3 Focus Order Level A — Focus follows a logical sequence: active tab, then panel content. Roving tabindex ensures only the active tab is in the tab order.
- 2.4.11 Focus Not Obscured (Minimum) Level AA — Focused tabs and panels are fully visible, not hidden behind other content.
- 2.4.13 Focus Appearance Level AAA — Both tabs and panels have visible focus indicators using a 2px solid outline with 3:1 contrast ratio.
-
2.5.8 Target Size (Minimum)
Level AA
— Tab triggers meet the 24x24px minimum target size via
min-height: 2.75rem. -
4.1.2 Name, Role, Value
Level A
— Each tab exposes its name (button text), role (
tab), and selected state (aria-selected) to the accessibility tree. - 3.2.3 Consistent Navigation Level AA — Tab navigation follows the standard WAI-ARIA pattern: arrow keys within the tablist, Tab to move into/out of the component.
// 06 · screen reader behavior
Screen Reader Behavior
When navigating to the tablist
- NVDA: "Web accessibility topics, tab list" — announces the tablist label, then "Perceivable, tab, selected, 1 of 4" — announces the tab name, role, state, and position
- JAWS: "Perceivable tab, 1 of 4" — announces tab name, role, and position
- VoiceOver: "Perceivable, selected, tab, 1 of 4, Web accessibility topics, tab group" — announces name, state, role, position, and group label
When arrowing to another tab
- NVDA: "Operable, tab, selected, 2 of 4" — announces the new tab name, role, state, and position
- JAWS: "Operable tab, 2 of 4" — announces tab name and position
- VoiceOver: "Operable, selected, tab, 2 of 4" — confirms the new selection
When tabbing into the panel
- NVDA: "Operable, tab panel" — announces the panel label (from
aria-labelledby) and role - JAWS: "Operable, tab panel" — same identification
- VoiceOver: "Operable, tab panel" — panel label and role, then content becomes navigable
After entering the panel, all screen readers allow normal content navigation using arrow keys, headings, links, and other standard navigation methods.
Position announcements
Screen readers announce "1 of 4", "2 of 4", etc. because the role="tab" elements are direct children of the role="tablist" container. This positional information helps users understand how many tabs exist and where they are in the set — even without seeing the visual layout.
// 07 · common mistakes
Common Mistakes
role="tablist", role="tab", or role="tabpanel"
All three roles are required. Without role="tablist", screen readers don't know these elements form a tab interface. Without role="tab", individual tabs are just buttons. Without role="tabpanel", the content area has no semantic connection to the tablist. Omitting any one of these breaks the entire pattern for assistive technology users.
aria-selected
Only the active tab should have aria-selected="true". All other tabs must have aria-selected="false". If you forget to update this when switching tabs, screen readers will announce the wrong tab as selected — or worse, multiple tabs as selected simultaneously. Always update aria-selected on every tab whenever the selection changes.
aria-controls and aria-labelledby connections
Each tab needs aria-controls pointing to its panel's id, and each panel needs aria-labelledby pointing back to its tab's id. These create the bidirectional relationship that assistive technology uses to connect tabs with their content. Without them, tabs and panels are semantically disconnected.
<button> elements, not <a> links. Links imply navigation to a new page or location. Tabs switch visible content within the same page. Using links confuses screen reader users who expect link behavior (navigation) but get tab behavior (content switching). Links also have different default keyboard behavior that conflicts with the tab pattern.
display: none without the hidden attribute
While CSS display: none hides panels visually, the hidden attribute provides a semantic signal that the element is not relevant. Use both: hidden as the HTML attribute and [role="tabpanel"][hidden] { display: none; } as a CSS safety net. This ensures panels are hidden even if CSS fails to load.
// 08 · native html vs. aria
Native HTML vs. ARIA
Unlike other patterns on this site, tabs have no native HTML equivalent. This table shows what native HTML still helps with and what ARIA must handle entirely.
| Feature | Native HTML contribution | ARIA requirement |
|---|---|---|
| Tab trigger element | <button> — focusable, keyboard-activatable |
role="tab" — overrides button semantics with tab semantics |
| Tab container | <div> — structural container |
role="tablist" — identifies the group as a tab interface |
| Panel container | <div> — structural container |
role="tabpanel" — identifies the content area |
| Selected state | None | aria-selected="true/false" — required on every tab |
| Tab-to-panel relationship | None | aria-controls on tab, aria-labelledby on panel |
| Tablist label | None | aria-label on the tablist describes the tab group's purpose |
| Arrow key navigation | None | JavaScript keydown handler — required by the pattern |
| Roving tabindex | None | JavaScript manages tabindex="0" / tabindex="-1" |
| Hiding inactive panels | hidden attribute — semantic "not relevant" |
None — hidden handles this natively |
<button> provides keyboard activation and focusability, and the hidden attribute handles panel visibility. The principle holds — use native HTML where you can, ARIA where you must.