NavigationFlagship guide
Tabs
A set of layered sections of content, one visible at a time, selected via a tab list.
Last verified against WCAG 2.2 and WAI-ARIA APG 1.2 on 2026-07-02.
Implementation
Manage your name, photo, and public bio.
Change your password and manage two-factor authentication.
View invoices and update your payment method.
tabs-pattern.tsx
function TabsPattern({ activationMode = "automatic" }) {
const [selected, setSelected] = useState(TABS[0].id);
const [focused, setFocused] = useState(TABS[0].id);
const tabRefs = useRef({});
function moveFocus(index) {
const tab = TABS[index];
setFocused(tab.id);
tabRefs.current[tab.id]?.focus();
// Automatic activation: selecting follows focus immediately.
if (activationMode === "automatic") setSelected(tab.id);
}
function onKeyDown(e) {
const i = TABS.findIndex(t => t.id === focused);
if (e.key === "ArrowRight") { e.preventDefault(); moveFocus((i + 1) % TABS.length); }
if (e.key === "ArrowLeft") { e.preventDefault(); moveFocus((i - 1 + TABS.length) % TABS.length); }
if (e.key === "Home") { e.preventDefault(); moveFocus(0); }
if (e.key === "End") { e.preventDefault(); moveFocus(TABS.length - 1); }
// Manual activation: arrowing only moves focus; Enter/Space commits it.
if (activationMode === "manual" && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
setSelected(focused);
}
}
return (
<div role="tablist" aria-label="Account settings" onKeyDown={onKeyDown}>
{TABS.map(tab => (
<button
key={tab.id}
ref={el => (tabRefs.current[tab.id] = el)}
role="tab"
id={`tab-${tab.id}`}
aria-selected={tab.id === selected}
aria-controls={`panel-${tab.id}`}
// Roving tabindex: only the focused tab is Tab-reachable.
tabIndex={tab.id === focused ? 0 : -1}
onClick={() => { setFocused(tab.id); setSelected(tab.id); }}
>
{tab.label}
</button>
))}
{TABS.map(tab => (
<div
key={tab.id}
role="tabpanel"
id={`panel-${tab.id}`}
aria-labelledby={`tab-${tab.id}`}
hidden={tab.id !== selected}
tabIndex={0}
>
{tab.content}
</div>
))}
</div>
);
}Live demo
Manage your name, photo, and public bio.
Change your password and manage two-factor authentication.
View invoices and update your payment method.
Required roles, states & properties
| Element | Attribute | Why |
|---|---|---|
| Tab list wrapper | role="tablist" | Identifies the group of tab buttons as a tablist, so AT announces the total count and enables tab-specific navigation commands. |
| Each tab | role="tab" + aria-selected | Identifies each button as a tab and communicates which one is currently active — announced as "selected." |
| Each tab | aria-controls | Associates the tab with the panel id it reveals. |
| Each tab | tabIndex (roving) | Only the active/focused tab has tabIndex=0; the rest are -1, so Tab moves from the tablist straight to panel content instead of stopping on every tab. |
| Each panel | role="tabpanel" + aria-labelledby | Identifies the panel and labels it via the tab that reveals it, so its accessible name matches the tab label. |
Keyboard interaction model
| Key | Behavior |
|---|---|
| Tab | Moves focus from the page into the tablist (landing on the active tab), then out to the active panel's content. |
| Right / Left Arrow | Moves focus to the next / previous tab, wrapping at the ends. In automatic mode, also activates it. |
| Home / End | Moves focus to the first / last tab. |
| Enter / Space | In manual activation mode, activates the focused tab. Not needed in automatic mode. |
Focus management rules
- Tab into the widget lands on the currently active tab, never on an inactive one.
- Arrow keys move focus between tabs using a roving tabindex — never real DOM focus loss.
- Tab out of the tablist moves focus directly to the active panel's content, skipping inactive tabs entirely.
WCAG 2.2 success criteria mapping
| SC | Name | Level | Why it applies |
|---|---|---|---|
| 2.1.1 | Keyboard | A | All functionality is operable through a keyboard interface with no specific timing. |
| 2.4.3 | Focus Order | A | Focusable components receive focus in an order that preserves meaning and operability. |
| 3.2.1 | On Focus | A | Receiving focus does not trigger an unexpected change of context. |
| 4.1.2 | Name, Role, Value | A | For all UI components, name, role, and value are programmatically determinable; states and changes are announced. |