tabpanel
A container for the content associated with a tab. Tab panels display content when their corresponding tab is selected, working together with tablist and tab roles to create a tabbed interface.
Overview
The tabpanel role identifies an element as a container for content associated with a tab. When a tab is selected, its corresponding tabpanel becomes visible while others are hidden.
Tab panels must be associated with their controlling tabs using aria-labelledby (referencing the tab's ID) or aria-label. The tab should have aria-controls pointing to the panel's ID.
Tab Pattern Overview
A complete tab interface requires three roles working together: tablist (container for tabs), tab (the clickable tab buttons), and tabpanel (the content containers).
Live Demo: Tab Interfaces
Horizontal Tabs
Overview
This is the content for the overview tab panel. It becomes visible when the corresponding tab is selected.
Features
This is the content for the features tab panel. It becomes visible when the corresponding tab is selected.
Pricing
This is the content for the pricing tab panel. It becomes visible when the corresponding tab is selected.
Vertical Tabs
Account Settings
Configure your account preferences here.
Security Settings
Configure your security preferences here.
Notifications Settings
Configure your notifications preferences here.
Privacy Settings
Configure your privacy preferences here.
Keyboard support: Use ← → for horizontal tabs, ↑ ↓ for vertical tabs. Home and End jump to first/last tab.
Code Examples
Basic Tab Structure
<!-- Basic Tab Panel Structure -->
<div class="tabs">
<!-- Tab List -->
<div role="tablist" aria-label="Product Information">
<button
role="tab"
aria-selected="true"
aria-controls="panel-1"
id="tab-1"
tabindex="0"
>
Overview
</button>
<button
role="tab"
aria-selected="false"
aria-controls="panel-2"
id="tab-2"
tabindex="-1"
>
Features
</button>
<button
role="tab"
aria-selected="false"
aria-controls="panel-3"
id="tab-3"
tabindex="-1"
>
Pricing
</button>
</div>
<!-- Tab Panels -->
<div
role="tabpanel"
id="panel-1"
aria-labelledby="tab-1"
tabindex="0"
>
<h2>Product Overview</h2>
<p>Overview content here...</p>
</div>
<div
role="tabpanel"
id="panel-2"
aria-labelledby="tab-2"
tabindex="0"
hidden
>
<h2>Features</h2>
<p>Features content here...</p>
</div>
<div
role="tabpanel"
id="panel-3"
aria-labelledby="tab-3"
tabindex="0"
hidden
>
<h2>Pricing</h2>
<p>Pricing content here...</p>
</div>
</div>Keyboard Navigation
<!-- Tab Implementation with Full Keyboard Support -->
<div class="tabs">
<div role="tablist" aria-label="Settings" id="settings-tablist">
<button role="tab" id="tab-account" aria-selected="true"
aria-controls="panel-account" tabindex="0">Account</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-account" aria-labelledby="tab-account">
<!-- Account content -->
</div>
</div>
<script>
const tablist = document.getElementById('settings-tablist');
const tabs = tablist.querySelectorAll('[role="tab"]');
const panels = document.querySelectorAll('[role="tabpanel"]');
tabs.forEach((tab, index) => {
tab.addEventListener('click', () => switchTab(index));
tab.addEventListener('keydown', (e) => {
let newIndex = index;
switch (e.key) {
case 'ArrowRight':
e.preventDefault();
newIndex = (index + 1) % tabs.length;
break;
case 'ArrowLeft':
e.preventDefault();
newIndex = (index - 1 + tabs.length) % tabs.length;
break;
case 'Home':
e.preventDefault();
newIndex = 0;
break;
case 'End':
e.preventDefault();
newIndex = tabs.length - 1;
break;
default:
return;
}
switchTab(newIndex);
tabs[newIndex].focus();
});
});
function switchTab(index) {
// Update tabs
tabs.forEach((tab, i) => {
tab.setAttribute('aria-selected', i === index);
tab.setAttribute('tabindex', i === index ? '0' : '-1');
});
// Update panels
panels.forEach((panel, i) => {
panel.hidden = i !== index;
});
}
</script>Vertical Tabs
<!-- Vertical Tab List -->
<div class="vertical-tabs">
<div
role="tablist"
aria-label="Settings sections"
aria-orientation="vertical"
class="tab-sidebar"
>
<button role="tab" aria-selected="true" aria-controls="v-panel-1"
id="v-tab-1" tabindex="0">General</button>
<button role="tab" aria-selected="false" aria-controls="v-panel-2"
id="v-tab-2" tabindex="-1">Display</button>
<button role="tab" aria-selected="false" aria-controls="v-panel-3"
id="v-tab-3" tabindex="-1">Sound</button>
</div>
<div class="tab-content">
<div role="tabpanel" id="v-panel-1" aria-labelledby="v-tab-1">
<!-- General settings -->
</div>
<!-- Other panels -->
</div>
</div>
<!-- For vertical tabs:
- Use aria-orientation="vertical"
- Arrow Up/Down navigate (not Left/Right)
- Home/End still work the same -->React Component
// React Tabs Component
import { useState, useRef, useCallback } from 'react';
interface Tab {
id: string;
label: string;
content: React.ReactNode;
}
interface TabsProps {
tabs: Tab[];
ariaLabel: string;
orientation?: 'horizontal' | 'vertical';
}
function Tabs({ tabs, ariaLabel, orientation = 'horizontal' }: TabsProps) {
const [activeIndex, setActiveIndex] = useState(0);
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
const handleKeyDown = useCallback((e: React.KeyboardEvent, index: number) => {
const isVertical = orientation === 'vertical';
const prevKey = isVertical ? 'ArrowUp' : 'ArrowLeft';
const nextKey = isVertical ? 'ArrowDown' : 'ArrowRight';
let newIndex = index;
switch (e.key) {
case nextKey:
e.preventDefault();
newIndex = (index + 1) % tabs.length;
break;
case prevKey:
e.preventDefault();
newIndex = (index - 1 + tabs.length) % tabs.length;
break;
case 'Home':
e.preventDefault();
newIndex = 0;
break;
case 'End':
e.preventDefault();
newIndex = tabs.length - 1;
break;
default:
return;
}
setActiveIndex(newIndex);
tabRefs.current[newIndex]?.focus();
}, [tabs.length, orientation]);
return (
<div className={`tabs ${orientation}`}>
<div
role="tablist"
aria-label={ariaLabel}
aria-orientation={orientation}
className="tab-list"
>
{tabs.map((tab, index) => (
<button
key={tab.id}
ref={(el) => { tabRefs.current[index] = el; }}
role="tab"
id={`tab-${tab.id}`}
aria-selected={index === activeIndex}
aria-controls={`panel-${tab.id}`}
tabIndex={index === activeIndex ? 0 : -1}
onClick={() => setActiveIndex(index)}
onKeyDown={(e) => handleKeyDown(e, index)}
className={`tab ${index === activeIndex ? 'active' : ''}`}
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab, index) => (
<div
key={tab.id}
role="tabpanel"
id={`panel-${tab.id}`}
aria-labelledby={`tab-${tab.id}`}
tabIndex={0}
hidden={index !== activeIndex}
className="tab-panel"
>
{tab.content}
</div>
))}
</div>
);
}
// Usage
const productTabs = [
{
id: 'overview',
label: 'Overview',
content: <div><h2>Product Overview</h2><p>Description...</p></div>,
},
{
id: 'features',
label: 'Features',
content: <div><h2>Features</h2><ul><li>Feature 1</li></ul></div>,
},
{
id: 'pricing',
label: 'Pricing',
content: <div><h2>Pricing</h2><p>Starting at $9.99</p></div>,
},
];
<Tabs tabs={productTabs} ariaLabel="Product information" />Automatic vs Manual Activation
<!-- Automatic vs Manual Tab Activation -->
<!-- AUTOMATIC ACTIVATION (Recommended) -->
<!-- Tab activates immediately on focus -->
<div role="tablist" aria-label="Automatic tabs">
<button role="tab" aria-selected="true" ...>Tab 1</button>
<button role="tab" aria-selected="false" ...>Tab 2</button>
</div>
<script>
// Automatic: activate on focus
tab.addEventListener('focus', () => {
switchTab(index);
});
tab.addEventListener('keydown', (e) => {
// Arrow keys move focus AND activate
if (e.key === 'ArrowRight') {
tabs[nextIndex].focus(); // This triggers activation
}
});
</script>
<!-- MANUAL ACTIVATION -->
<!-- User must press Enter/Space after focusing -->
<div role="tablist" aria-label="Manual tabs">
<button role="tab" aria-selected="true" ...>Tab 1</button>
<button role="tab" aria-selected="false" ...>Tab 2</button>
</div>
<script>
// Manual: focus doesn't activate
tab.addEventListener('keydown', (e) => {
if (e.key === 'ArrowRight') {
e.preventDefault();
tabs[nextIndex].focus(); // Just move focus
}
if (e.key === 'Enter' || e.key === ' ') {
switchTab(focusedIndex); // Explicitly activate
}
});
</script>
<!-- Automatic activation is generally preferred for better UX -->Dynamic/Lazy-Loaded Content
<!-- Tab Panel with Lazy-Loaded Content -->
<div class="tabs">
<div role="tablist" aria-label="Dashboard sections">
<button role="tab" aria-selected="true"
aria-controls="panel-analytics" id="tab-analytics">
Analytics
</button>
<button role="tab" aria-selected="false"
aria-controls="panel-reports" id="tab-reports">
Reports
</button>
</div>
<div
role="tabpanel"
id="panel-analytics"
aria-labelledby="tab-analytics"
aria-busy="false"
tabindex="0"
>
<!-- Content loaded -->
</div>
<div
role="tabpanel"
id="panel-reports"
aria-labelledby="tab-reports"
aria-busy="true"
tabindex="0"
hidden
>
<!-- Content loading... -->
<div role="status" aria-live="polite">
Loading reports...
</div>
</div>
</div>
<!-- Use aria-busy="true" while content is loading
Use aria-live region to announce when loading completes -->Keyboard Support
Keyboard controls apply when focus is on a tab within the tablist:
Wraps from last to first
Wraps from first to last
When aria-orientation="vertical"
When aria-orientation="vertical"
Works in both orientations
Works in both orientations
From the active tab
Best Practices
Use aria-labelledby on tabpanel to reference its controlling tab
Set tabindex="0" on tab panels to allow keyboard focus
Use hidden attribute or display:none for inactive panels
Implement roving tabindex on tabs (only active tab is tabbable)
Use aria-orientation="vertical" for vertically stacked tabs
Ensure tab panel content is fully accessible when visible
Don't use tabs for navigation between pages (use links instead)
Don't nest tab interfaces within each other
Don't remove hidden panels from DOM if they have focusable elements
Don't forget the bidirectional relationship between tab and tabpanel
Supported ARIA Attributes
aria-labelledbyReferences the tab that controls this panel
aria-labelAccessible name if no visible label exists
aria-busyIndicates if panel content is loading
tabindex="0"Makes the panel keyboard focusable
hiddenHides inactive panels
Common Use Cases
Accessibility Notes
Focus Flow
When tabbing from the tab list, focus should move to the active tab panel. Setting tabindex="0" on panels allows users to focus them directly, then Tab into interactive content within.
Screen Reader Announcements
Screen readers announce tab panels with their label (from aria-labelledby). When switching tabs, the new panel's content should be announced. Consider using aria-live regions for dynamically loaded content.
Tabs vs Navigation
Tabs should show/hide content on the same page. If clicking a "tab" navigates to a new URL, use regular links with proper navigation patterns instead. Tabs are for in-page content organization, not site navigation.

