Tabs Component
Create accessible tabbed interfaces with proper ARIA roles, keyboard navigation, and screen reader support.
The Bad (Inaccessible)
<div class="tabs">
<div class="tab" onclick="showTab(1)">Tab 1</div>
<div class="tab" onclick="showTab(2)">Tab 2</div>
</div>
<div class="tab-content" id="content-1">...</div>
Accessibility-Ready Code
<!-- Gold Standard: Proper tablist with roles -->
<div class="tabs-component">
<div role="tablist" aria-label="Product features">
<!-- Tab 1 -->
<button
type="button"
role="tab"
id="tab-1"
aria-selected="true"
aria-controls="panel-1"
tabindex="0"
class="tab-button"
>
Overview
</button>
<!-- Tab 2 -->
<button
type="button"
role="tab"
id="tab-2"
aria-selected="false"
aria-controls="panel-2"
tabindex="-1"
class="tab-button"
>
Features
</button>
<!-- Tab 3 -->
<button
type="button"
role="tab"
id="tab-3"
aria-selected="false"
aria-controls="panel-3"
tabindex="-1"
class="tab-button"
>
Pricing
</button>
</div>
<!-- Tab Panels -->
<div class="tab-panels">
<div
id="panel-1"
role="tabpanel"
aria-labelledby="tab-1"
tabindex="0"
class="tab-panel"
>
<h2>Overview</h2>
<p>Product overview content...</p>
</div>
<div
id="panel-2"
role="tabpanel"
aria-labelledby="tab-2"
tabindex="0"
class="tab-panel"
hidden
>
<h2>Features</h2>
<p>Features list...</p>
</div>
<div
id="panel-3"
role="tabpanel"
aria-labelledby="tab-3"
tabindex="0"
class="tab-panel"
hidden
>
<h2>Pricing</h2>
<p>Pricing information...</p>
</div>
</div>
</div>
<script>
const tabs = document.querySelectorAll('[role="tab"]');
const panels = document.querySelectorAll('[role="tabpanel"]');
tabs.forEach((tab, index) => {
tab.addEventListener('click', () => activateTab(tab));
tab.addEventListener('keydown', (e) => {
if (e.key === 'ArrowRight') {
e.preventDefault();
tabs[index + 1]?.focus() || tabs[0].focus();
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
tabs[index - 1]?.focus() || tabs[tabs.length - 1].focus();
}
});
});
function activateTab(selectedTab) {
tabs.forEach(t => {
t.setAttribute('aria-selected', 'false');
t.setAttribute('tabindex', '-1');
});
panels.forEach(p => p.hidden = true);
selectedTab.setAttribute('aria-selected', 'true');
selectedTab.setAttribute('tabindex', '0');
const panel = document.getElementById(
selectedTab.getAttribute('aria-controls')
);
panel.hidden = false;
panel.focus();
}
</script>
// Gold Standard: Proper tablist with roles
.tabs-component
div(role="tablist" aria-label="Product features")
// Tab 1
button(
type="button"
role="tab"
id="tab-1"
aria-selected="true"
aria-controls="panel-1"
tabindex="0"
class="tab-button"
) Overview
// Tab 2
button(
type="button"
role="tab"
id="tab-2"
aria-selected="false"
aria-controls="panel-2"
tabindex="-1"
class="tab-button"
) Features
// Tab 3
button(
type="button"
role="tab"
id="tab-3"
aria-selected="false"
aria-controls="panel-3"
tabindex="-1"
class="tab-button"
) Pricing
// Tab Panels
.tab-panels
div(
id="panel-1"
role="tabpanel"
aria-labelledby="tab-1"
tabindex="0"
class="tab-panel"
)
h2 Overview
p Product overview content...
div(
id="panel-2"
role="tabpanel"
aria-labelledby="tab-2"
tabindex="0"
class="tab-panel"
hidden
)
h2 Features
p Features list...
div(
id="panel-3"
role="tabpanel"
aria-labelledby="tab-3"
tabindex="0"
class="tab-panel"
hidden
)
h2 Pricing
p Pricing information...
const Tabs = ({ children }) => {
const [activeTab, setActiveTab] = useState(0);
return (
<div className="tabs-component">
<div role="tablist" aria-label="Product features">
{children.map((child, index) => (
<button
key={index}
type="button"
role="tab"
:id="`tab-${index}`"
aria-selected={activeTab === index}
:aria-controls="`panel-${index}`"
:tabIndex={activeTab === index ? 0 : -1}
onClick={() => setActiveTab(index)}
onKeyDown={(e) => {
if (e.key === 'ArrowRight') {
setActiveTab((index + 1) % children.length);
} else if (e.key === 'ArrowLeft') {
setActiveTab((index - 1 + children.length) % children.length);
}
}}
>
{child.props.label}
</button>
))}
</div>
<div className="tab-panels">
{children.map((child, index) => (
<div
key={index}
:id="`panel-${index}`"
role="tabpanel"
:aria-labelledby="`tab-${index}`"
tabIndex={0}
hidden={activeTab !== index}
>
{activeTab === index && child.props.children}
</div>
))}
</div>
</div>
);
};
<template>
<div class="tabs-component">
<div role="tablist" :aria-label="label">
<button
v-for="(tab, index) in tabs"
:key="index"
type="button"
role="tab"
:id="`tab-${index}`"
:aria-selected="activeTab === index"
:aria-controls="`panel-${index}`"
:tabindex="activeTab === index ? 0 : -1"
@click="activeTab = index"
@keydown="handleKeydown($event, index)"
>
{{ tab.label }}
</button>
</div>
<div class="tab-panels">
<div
v-for="(tab, index) in tabs"
:key="index"
:id="`panel-${index}`"
role="tabpanel"
:aria-labelledby="`tab-${index}`"
tabindex="0"
v-show="activeTab === index"
>
<slot v-if="activeTab === index" :name="`tab-${index}`" />
</div>
</div>
</div>
</template>
<script>
export default {
props: ['tabs', 'label'],
data() {
return { activeTab: 0 }
},
methods: {
handleKeydown(e, index) {
if (e.key === 'ArrowRight') {
this.activeTab = (index + 1) % this.tabs.length;
} else if (e.key === 'ArrowLeft') {
this.activeTab = (index - 1 + this.tabs.length) % this.tabs.length;
}
}
}
}
</script>
<div className="tabs-component">
<div role="tablist" aria-label="Product features" className="flex border-b">
{tabs.map((tab, index) => (
<button
key={index}
type="button"
role="tab"
:aria-selected={activeTab === index}
:aria-controls="`panel-${index}`"
:tabIndex={activeTab === index ? 0 : -1}
onClick={() => setActiveTab(index)}
className={`
px-6 py-3 font-semibold border-b-2 focus:outline-none
${activeTab === index
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-600'
}
`}
>
{tab.label}
</button>
))}
</div>
<div className="tab-panels mt-6">
{tabs.map((tab, index) => (
<div
key={index}
role="tabpanel"
:aria-labelledby="`tab-${index}`"
tabIndex={0}
className={activeTab === index ? '' : 'hidden'}
>
{activeTab === index && tab.content}
</div>
))}
</div>
</div>
The Standard
Tabs organize content into separate panels where only one is visible at a time. Proper ARIA roles and keyboard navigation are essential for accessibility.
WCAG Criteria
- 4.1.2 Name, Role, Value: Proper ARIA roles.
- 2.4.3 Focus Order: Logical tab order.
- 3.2.1 On Focus: No focus changes without user action.
โ The Bad (Inaccessible)
Whatโs Wrong?
- Using
divwithonclick: Not keyboard accessible. - No tab/panel roles: Screen reader canโt identify tabs.
- Forgetting
aria-selected: Screen reader doesnโt know active tab. - No arrow key support: Poor keyboard UX.
โ The Good (Accessibility-Ready Code)
Why This Works
- Proper Roles:
role="tablist",role="tab",role="tabpanel". - Dynamic aria-selected: Updates on activation.
- tabindex Management: Only active tab is
tabindex="0". - Keyboard Navigation: Arrow keys move between tabs.
Accessibility Checklist
- Use
<div role="tablist">for the tab container. - Use
<button role="tab">for each tab button. - Add
aria-selectedthat updates dynamically. - Manage
tabindex: active=0, inactive=-1. - Use
<div role="tabpanel">for content panels. - Link panels to tabs with
aria-labelledby. - Add
aria-controlson tabs pointing to panel IDs. - Support arrow keys for navigation.
<div role="tablist" aria-label="Product features">
<button
type="button"
role="tab"
aria-selected="true"
aria-controls="panel-1"
tabindex="0"
>
Overview
</button>
<button
type="button"
role="tab"
aria-selected="false"
aria-controls="panel-2"
tabindex="-1"
>
Features
</button>
</div>
<div
id="panel-1"
role="tabpanel"
aria-labelledby="tab-1"
tabindex="0"
>
<h2>Overview</h2>
</div>
<div
id="panel-2"
role="tabpanel"
aria-labelledby="tab-2"
tabindex="0"
hidden
>
<h2>Features</h2>
</div>
<script>
tabs.forEach(tab => {
tab.addEventListener('click', () => activateTab(tab));
tab.addEventListener('keydown', handleArrowKeys);
});
</script>
Technical Deep Dive
Screen Reader Announcements
- NVDA: โOverview, tab, 1 of 3, selectedโ
- VoiceOver: โOverview, tab, 1 of 3, selectedโ
- JAWS: โOverview, tab, 1 of 3, selectedโ
Best Practice: Tab vs. Accordion
Use tabs when content is related but users will only need to view one section at a time, like different product features or settings categories. Use accordions when each section can be independently expanded/collapsed, like in a FAQ where users might want multiple sections open simultaneously.
Interactive Behavioral Lab
Interactive Sandbox
๐ฌ Technical Internals
Understand how this component is processed by the browser and Assistive Technology (AT). This section bridges the gap between visual code and the hidden logic that powers accessibility.
๐ฒ Accessibility Tree
The data structure used by screen readers to "see" your page. It translates HTML roles and attributes into standardized objects.
โ๏ธ Event Logic
Expected behavioral standards for keyboard navigation and state transitions. Crucial for users who don't use a mouse.
- Focus: Highlights via
:focus-visible - Activation: Responds to
EnterandSpace - Role: Identified as
Tabs