Accessible Tabs Pattern
APG tabs with tablist/tab/tabpanel, arrow key navigation, aria-selected, and focus management without losing the active panel.
- accessibility
- accessible-tabs
Tabs switch between peer panels in one view. Accessibility needs correct roles, keyboard model, and state (aria-selected, aria-controls). A row of buttons that only works with a mouse is not a tab pattern.
Docs: APG Tabs, MDN tab role.
Structure
<div role="tablist" aria-label="Account">
<button
role="tab"
id="tab-profile"
aria-selected="true"
aria-controls="panel-profile"
tabindex="0"
>
Profile
</button>
<button
role="tab"
id="tab-billing"
aria-selected="false"
aria-controls="panel-billing"
tabindex="-1"
>
Billing
</button>
</div>
<div role="tabpanel" id="panel-profile" aria-labelledby="tab-profile">
…
</div>
<div role="tabpanel" id="panel-billing" aria-labelledby="tab-billing" hidden>
…
</div>
- Only one tab
aria-selected="true" - Roving
tabindex: selected tab0, others-1 - Panels labeled by their tabs
Keyboard
| Key | Behavior |
|---|---|
| Tab | Enter/leave tablist (one stop for the active tab) |
| Left/Right (horizontal) | Move selection/focus across tabs |
| Home/End | First/last |
| Space/Enter | Activate if activation is manual |
APG documents automatic vs manual activation. Automatic: arrow changes panel immediately. Manual: arrow moves focus; Enter selects.
Focus notes
When activating a tab, focus usually stays on the tab. Users Tab into the panel content. Don’t force focus into the panel unless a specific UX needs it (and then be consistent).
Do you need tabs?
If content is separate pages, use links/routing. Tabs are for in-place peer panels. For mobile, sometimes a select or stacked sections works better.
React state sketch
function Tabs({ items }: { items: { id: string; label: string; panel: React.ReactNode }[] }) {
const [active, setActive] = useState(items[0].id);
return (
<>
<div role="tablist" aria-label="Sections">
{items.map((item) => (
<button
key={item.id}
role="tab"
type="button"
aria-selected={active === item.id}
aria-controls={`panel-${item.id}`}
id={`tab-${item.id}`}
tabIndex={active === item.id ? 0 : -1}
onClick={() => setActive(item.id)}
>
{item.label}
</button>
))}
</div>
{items.map((item) => (
<div
key={item.id}
role="tabpanel"
id={`panel-${item.id}`}
aria-labelledby={`tab-${item.id}`}
hidden={active !== item.id}
>
{item.panel}
</div>
))}
</>
);
}
Add onKeyDown for arrows in production.
Footguns
- All tabs in tab order (
tabindex=0on every tab). - Panels not hidden correctly to AT.
- Missing
aria-controls/ ids. - Nested interactive tabs without keyboard support.
Interview out-loud answer
“Tabs use tablist/tab/tabpanel with aria-selected and roving tabindex. Arrow keys move between tabs; Tab moves into the page. I hide inactive panels and wire aria-controls. For real navigation between pages I use links instead.”
Dynamic tabs
When tabs can be added/removed (browser-like UIs), manage focus when the active tab is removed — move to the previous or next tab and update aria-selected. Don’t leave aria-controls pointing at detached panels.
Extra practice
Write a minimal demo in a scratch file or the playground: one happy path, one failure path, and one boundary input. If you cannot exhibit a bug that the pattern prevents, you do not own the concept yet — re-read the primary docs linked below and tighten the example until the failure is obvious.
Notes from real codebases
Teams that succeed here keep the rules mechanical: lint where possible, CI for the rest, and a short human checklist for what automation cannot see. Document exceptions with an owner name and a removal date so “temporary” escapes do not become permanent architecture.
Related on this site
- Accessible modals patterns
- Focus management
- ARIA roles overview
- Keyboard accessibility checklist
- Accessible menus pattern
Further reading
Related guides
- Accessible Combobox PatternBuild or evaluate comboboxes with APG keyboard behavior, aria-expanded/activedescendant, and filterable listbox pairing.
- Accessible Forms ErrorsLabel inputs, associate errors with fields, announce failures, and avoid placeholder-only forms that break accessibility.
- Accessible Menus PatternAPG menu and menubar keyboard model — arrow navigation, escape to close, aria-expanded, and when a disclosure is enough.
- Accessible Modals PatternsModal dialogs that work: focus trap, Escape, return focus, aria-modal, and why native dialog or APG patterns beat div soup.
- Accessible Names ComputationHow browsers compute accessible names from content, labels, aria-label, and labelledby — and how that powers Testing Library queries.