Make tabs keyboard navigable
Tab components implement the ARIA tabs pattern with proper roles, states, and keyboard navigation.
- Use tablist, tab, and tabpanel roles
- Only one tab in the tab order at a time (roving tabindex)
- Arrow keys navigate between tabs, Tab moves to panel
- Connect tabs to panels with aria-controls/aria-labelledby
Rule Details
Accessible tabs require proper ARIA roles and keyboard navigation to support all users.
Code Example
<div class="tabs">
<div role="tablist" aria-label="Product information">
<button
role="tab"
id="tab-1"
aria-selected="true"
aria-controls="panel-1"
tabindex="0"
>
Description
</button>
<button
role="tab"
id="tab-2"
aria-selected="false"
aria-controls="panel-2"
tabindex="-1"
>
Specifications
</button>
<button
role="tab"
id="tab-3"
aria-selected="false"
aria-controls="panel-3"
tabindex="-1"
>
Reviews
</button>
</div>
<div
role="tabpanel"
id="panel-1"
aria-labelledby="tab-1"
tabindex="0"
>
<p>Product description content...</p>
</div>
<div
role="tabpanel"
id="panel-2"
aria-labelledby="tab-2"
tabindex="0"
hidden
>
<p>Technical specifications...</p>
</div>
<div
role="tabpanel"
id="panel-3"
aria-labelledby="tab-3"
tabindex="0"
hidden
>
<p>Customer reviews...</p>
</div>
</div>Why It Matters
Incorrectly implemented tabs trap keyboard users or leave screen reader users unable to understand the relationship between tabs and their content.
ARIA Tabs Pattern
| Element | Role | Key Attributes |
|---|---|---|
| Container | tablist | aria-label or aria-labelledby |
| Tab button | tab | aria-selected, aria-controls, tabindex |
| Content | tabpanel | aria-labelledby, tabindex="0" |
Keyboard Navigation
| Key | Action |
|---|---|
Tab | Move focus into/out of tablist |
Arrow Left/Right | Move between tabs |
Home | Move to first tab |
End | Move to last tab |
Enter/Space | Activate focused tab (manual activation) |
React Tabs Component
import { useState, useRef, useId, KeyboardEvent } from 'react'
interface Tab {
id: string
label: string
content: React.ReactNode
}
interface TabsProps {
tabs: Tab[]
label: string
defaultTab?: number
manual?: boolean // Manual or automatic activation
}
export function Tabs({
tabs,
label,
defaultTab = 0,
manual = false
}: TabsProps) {
const [activeIndex, setActiveIndex] = useState(defaultTab)
const [focusIndex, setFocusIndex] = useState(defaultTab)
const tabRefs = useRef<(HTMLButtonElement | null)[]>([])
const baseId = useId()
const getTabId = (index: number) => `${baseId}-tab-${index}`
const getPanelId = (index: number) => `${baseId}-panel-${index}`
const activateTab = (index: number) => {
setActiveIndex(index)
setFocusIndex(index)
}
const focusTab = (index: number) => {
setFocusIndex(index)
tabRefs.current[index]?.focus()
// Automatic activation: activate on focus
if (!manual) {
setActiveIndex(index)
}
}
const handleKeyDown = (e: KeyboardEvent, index: number) => {
let nextIndex: number | null = null
switch (e.key) {
case 'ArrowLeft':
e.preventDefault()
nextIndex = index > 0 ? index - 1 : tabs.length - 1
break
case 'ArrowRight':
e.preventDefault()
nextIndex = index < tabs.length - 1 ? index + 1 : 0
break
case 'Home':
e.preventDefault()
nextIndex = 0
break
case 'End':
e.preventDefault()
nextIndex = tabs.length - 1
break
case 'Enter':
case ' ':
if (manual) {
e.preventDefault()
activateTab(index)
}
break
}
if (nextIndex !== null) {
focusTab(nextIndex)
}
}
return (
<div className="tabs">
<div
role="tablist"
aria-label={label}
className="tabs__list"
>
{tabs.map((tab, index) => (
<button
key={tab.id}
ref={(el) => { tabRefs.current[index] = el }}
role="tab"
id={getTabId(index)}
aria-selected={activeIndex === index}
aria-controls={getPanelId(index)}
tabIndex={focusIndex === index ? 0 : -1}
onClick={() => activateTab(index)}
onKeyDown={(e) => handleKeyDown(e, index)}
className={`tabs__tab ${activeIndex === index ? 'tabs__tab--active' : ''}`}
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab, index) => (
<div
key={tab.id}
role="tabpanel"
id={getPanelId(index)}
aria-labelledby={getTabId(index)}
tabIndex={0}
hidden={activeIndex !== index}
className="tabs__panel"
>
{tab.content}
</div>
))}
</div>
)
}Usage
<Tabs
label="Account settings"
tabs={[
{
id: 'profile',
label: 'Profile',
content: <ProfileSettings />
},
{
id: 'security',
label: 'Security',
content: <SecuritySettings />
},
{
id: 'notifications',
label: 'Notifications',
content: <NotificationSettings />
}
]}
/>Vertical Tabs
interface VerticalTabsProps extends TabsProps {
orientation?: 'horizontal' | 'vertical'
}
export function VerticalTabs({
tabs,
label,
defaultTab = 0,
orientation = 'vertical'
}: VerticalTabsProps) {
const [activeIndex, setActiveIndex] = useState(defaultTab)
const tabRefs = useRef<(HTMLButtonElement | null)[]>([])
const baseId = useId()
const handleKeyDown = (e: KeyboardEvent, index: number) => {
// Vertical tabs use Up/Down instead of Left/Right
const prevKey = orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft'
const nextKey = orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight'
let nextIndex: number | null = null
if (e.key === prevKey) {
e.preventDefault()
nextIndex = index > 0 ? index - 1 : tabs.length - 1
} else if (e.key === nextKey) {
e.preventDefault()
nextIndex = index < tabs.length - 1 ? index + 1 : 0
}
if (nextIndex !== null) {
setActiveIndex(nextIndex)
tabRefs.current[nextIndex]?.focus()
}
}
return (
<div className={`tabs tabs--${orientation}`}>
<div
role="tablist"
aria-label={label}
aria-orientation={orientation}
className="tabs__list"
>
{/* ... same as horizontal */}
</div>
{/* ... panels */}
</div>
)
}With Icons
interface TabWithIcon {
id: string
label: string
icon: React.ReactNode
content: React.ReactNode
}
function TabButton({ tab, isActive, ...props }: {
tab: TabWithIcon
isActive: boolean
}) {
return (
<button
role="tab"
aria-selected={isActive}
className={`tabs__tab ${isActive ? 'tabs__tab--active' : ''}`}
{...props}
>
<span className="tabs__icon" aria-hidden="true">
{tab.icon}
</span>
<span className="tabs__label">{tab.label}</span>
</button>
)
}Styling
.tabs__list {
display: flex;
gap: 0;
border-bottom: 1px solid #ddd;
}
.tabs__tab {
padding: 0.75rem 1.5rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: #666;
font-size: 1rem;
cursor: pointer;
transition: border-color 0.2s, color 0.2s;
}
.tabs__tab:hover {
color: #333;
}
.tabs__tab:focus-visible {
outline: 2px solid #0066cc;
outline-offset: -2px;
}
.tabs__tab--active {
color: #0066cc;
border-bottom-color: #0066cc;
font-weight: 600;
}
.tabs__panel {
padding: 1.5rem 0;
}
.tabs__panel:focus {
outline: none;
}
.tabs__panel:focus-visible {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
/* Vertical tabs */
.tabs--vertical {
display: flex;
gap: 2rem;
}
.tabs--vertical .tabs__list {
flex-direction: column;
border-bottom: none;
border-right: 1px solid #ddd;
}
.tabs--vertical .tabs__tab {
border-bottom: none;
border-right: 2px solid transparent;
text-align: left;
}
.tabs--vertical .tabs__tab--active {
border-right-color: #0066cc;
}Automatic vs Manual Activation
// Automatic: Tab activates on focus (default, better UX)
<Tabs tabs={tabs} label="Settings" manual={false} />
// Manual: Tab activates on Enter/Space (use for destructive operations)
<Tabs tabs={tabs} label="Settings" manual={true} />Verification
- Tab into tablist (focus on active tab)
- Use Arrow keys to navigate between tabs
- Verify only active tab is in tab order
- Press Tab to move to panel content
- Test Home/End keys
- Verify screen reader announces tab roles and states
- Check panel is associated with tab via aria-labelledby
Only the active tab should have tabindex="0". All other tabs should have tabindex="-1". This is called "roving tabindex" and ensures efficient keyboard navigation.
Use with AI
Copy these prompts to use with your AI assistant, or install the MCP server to use directly from Claude, Cursor, or Windsurf.
Check
Verify implementation
Verify tabs use tablist/tab/tabpanel roles, aria-selected state, and arrow key navigation between tabs.
Fix
Auto-fix issues
Implement tabs with role='tablist', role='tab', role='tabpanel', aria-selected, and proper keyboard interaction patterns.
Explain
Learn more
Explain the ARIA tabs design pattern and how proper implementation ensures keyboard and screen reader accessibility.
Review
Code review
Review templates, server-rendered HTML, and shared components that output markup related to Make tabs keyboard navigable. Flag exact elements, attributes, and routes where the rendered HTML violates the rule.