Tabs | Component Deep Dive #37: Tabs — Scroll-Snap, ARIA, and Keyboard Arrows
User clicks a tab, content switches. Looks simple, but 90% of implementations forget keyboard users and screen readers.
Tabs are the foundation of information density management. When a page has too much content to display at once, tabs group it into sections users switch between. But "switchable" is only the starting point — a real tab component must satisfy the complete interaction spec of the WAI-ARIA Tab Pattern.
Core Principle
Tabs consist of two parts: the tab list (tablist) and content panels (tabpanel). Each tab points to its panel via aria-controls, and each panel points back to its tab via aria-labelledby. The selected state is marked with aria-selected="true", and unselected panels are hidden with the hidden attribute.
The key is focus management: the tab list uses roving tabindex — only the currently selected tab has tabindex="0", all others have tabindex="-1". Users move focus between tabs with left/right arrow keys, and the panel switches automatically as focus moves.
Implementation
<div class="tabs">
<div class="tablist" role="tablist" aria-label="Project Settings">
<button class="tab" role="tab" id="tab-general"
aria-controls="panel-general" aria-selected="true" tabindex="0">
General
</button>
<button class="tab" role="tab" id="tab-advanced"
aria-controls="panel-advanced" aria-selected="false" tabindex="-1">
Advanced
</button>
<button class="tab" role="tab" id="tab-permissions"
aria-controls="panel-permissions" aria-selected="false" tabindex="-1">
Permissions
</button>
</div>
<div class="tabpanel" role="tabpanel" id="panel-general"
aria-labelledby="tab-general">
<p>General settings content</p>
</div>
<div class="tabpanel" role="tabpanel" id="panel-advanced"
aria-labelledby="tab-advanced" hidden>
<p>Advanced settings content</p>
</div>
<div class="tabpanel" role="tabpanel" id="panel-permissions"
aria-labelledby="tab-permissions" hidden>
<p>Permissions settings content</p>
</div>
</div>
const tablist = document.querySelector('[role="tablist"]');
const tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
const panels = Array.from(document.querySelectorAll('[role="tabpanel"]'));
function activateTab(tab) {
// Deactivate all
tabs.forEach(t => {
t.setAttribute('aria-selected', 'false');
t.setAttribute('tabindex', '-1');
});
panels.forEach(p => p.setAttribute('hidden', ''));
// Activate selected
tab.setAttribute('aria-selected', 'true');
tab.setAttribute('tabindex', '0');
tab.focus();
document.getElementById(tab.getAttribute('aria-controls'))
.removeAttribute('hidden');
}
tablist.addEventListener('click', e => {
const tab = e.target.closest('[role="tab"]');
if (tab) activateTab(tab);
});
tablist.addEventListener('keydown', e => {
const idx = tabs.indexOf(document.activeElement);
if (idx === -1) return;
const horiz = tablist.getAttribute('aria-orientation') !== 'vertical';
let newIdx = idx;
if ((horiz && e.key === 'ArrowRight') ||
(!horiz && e.key === 'ArrowDown')) {
e.preventDefault();
newIdx = (idx + 1) % tabs.length;
} else if ((horiz && e.key === 'ArrowLeft') ||
(!horiz && e.key === 'ArrowUp')) {
e.preventDefault();
newIdx = (idx - 1 + tabs.length) % tabs.length;
} else if (e.key === 'Home') {
e.preventDefault();
newIdx = 0;
} else if (e.key === 'End') {
e.preventDefault();
newIdx = tabs.length - 1;
}
if (newIdx !== idx) activateTab(tabs[newIdx]);
});
Horizontal Scroll Tabs
When there are too many tabs, don't wrap — use horizontal scrolling with scroll-snap:
.tablist {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
scrollbar-width: none;
}
.tablist::-webkit-scrollbar { display: none; }
.tab {
scroll-snap-align: start;
flex-shrink: 0;
padding: 12px 20px;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
white-space: nowrap;
font: inherit;
color: #666;
transition: color 0.2s, border-color 0.2s;
}
.tab[aria-selected="true"] {
color: #2563eb;
border-bottom-color: #2563eb;
}
Common Pitfalls
Using display:none to switch panels —
display:nonecauses iframes/videos inside panels to reload. Thehiddenattribute is better — it's semantically equivalent but clearer. To preserve state, usevisibility:hiddenwithposition:absolute.Forgetting arrow key navigation — Many implementations only support click switching. Keyboard users must press Tab through every tab before reaching content. Correct approach: roving tabindex + arrow keys.
Wrong Tab key behavior — Pressing Tab should move focus from the current tab directly to its panel content, not to the next tab. Roving tabindex ensures only the active tab is in the tab sequence.
Missing ARIA states — Screen reader users need
aria-selectedto know which tab is active, andaria-controlsto know which panel a tab governs. Without these attributes, tabs are completely invisible to assistive technology.
Underlying Logic
Tabs are essentially a state machine: activeIndex determines which tab is selected and which panel is visible. All interactions — clicks, arrow keys, Home/End — simply update activeIndex, then a single activateTab function synchronizes the DOM.
The core idea of roving tabindex: manage focus within a container using directional keys, and let Tab key only jump between containers. This gives keyboard users the same navigation efficiency as mouse users — one keypress per tab switch.
本文是「组件深度解析」系列第37篇。每篇拆解一个前端组件的核心原理与实现细节。
Top comments (0)