When to use
Tabs swap one chunk of content for another inside a single region — settings sections, dashboard views, filter buckets. Use them when:
- the user picks one of several views,
- the views all answer the same question (“show me these settings”), and
- the content fits in roughly one screen so they don’t lose context when swapping.
If users need to compare two views side by side, or the content is long enough to scroll past, use sections with headings instead — tabs hide content, and hidden content is forgotten.
The pattern
Update your name, photo, and short bio. The bits people see when they hover your handle.
Choose which events email you. We default everyone to weekly digests — if that’s wrong, change it here.
Card on file, invoices, and the plan you’re on. Most people never visit this page once.
This is the WAI-ARIA Authoring Practices (opens in new tab) tabs pattern. Three roles, one keyboard model.
<div role="tablist" aria-label="Account settings">
<button role="tab" id="tab-profile"
aria-controls="panel-profile" aria-selected="true" tabindex="0">
Profile
</button>
<button role="tab" id="tab-notifications"
aria-controls="panel-notifications" aria-selected="false" tabindex="-1">
Notifications
</button>
<button role="tab" id="tab-billing"
aria-controls="panel-billing" aria-selected="false" tabindex="-1">
Billing
</button>
</div>
<div role="tabpanel" id="panel-profile"
aria-labelledby="tab-profile" tabindex="0">
…
</div>
<div role="tabpanel" id="panel-notifications"
aria-labelledby="tab-notifications" tabindex="0" hidden>…</div>
<div role="tabpanel" id="panel-billing"
aria-labelledby="tab-billing" tabindex="0" hidden>…</div> The bits that matter:
role="tablist"with an accessible name (aria-labelhere, oraria-labelledbyif there’s a visible heading)role="tab"on each tab. Use real<button>elements so they’re focusable, activatable, and announce their rolearia-controlspoints each tab at the panel it showsaria-selected="true"on exactly one tab;"false"on the rest- Roving tabindex: the selected tab is
tabindex="0"(in the tab order), the rest aretabindex="-1"(focusable by arrow keys but skipped by Tab). This is what makes Tab move out of the tab list instead of cycling through every tab role="tabpanel"on each panel, named by the tab viaaria-labelledbytabindex="0"on each panel so keyboard users can scroll/focus it directly. The panel only needstabindexif it doesn’t contain its own focusable content; if it does, you can drop it.
The keyboard model
This is what every “tabs” implementation gets wrong. The model is:
- Tab moves into and out of the tab list — never between tabs
- Arrow keys (Left/Right for horizontal, Up/Down for vertical) move between tabs and change selection automatically
- Home / End jump to the first/last tab
- Tab again moves focus from the tab list into the panel content
tabs.forEach((tab, index) => {
tab.addEventListener('click', () => select(tab));
tab.addEventListener('keydown', (event) => {
let target;
if (event.key === 'ArrowRight') target = tabs[(index + 1) % tabs.length];
else if (event.key === 'ArrowLeft') target = tabs[(index - 1 + tabs.length) % tabs.length];
else if (event.key === 'Home') target = tabs[0];
else if (event.key === 'End') target = tabs[tabs.length - 1];
if (target) {
event.preventDefault();
select(target);
}
});
});
function select(target) {
tabs.forEach((tab) => {
const isSelected = tab === target;
tab.setAttribute('aria-selected', String(isSelected));
tab.tabIndex = isSelected ? 0 : -1;
});
panels.forEach((panel) => {
panel.toggleAttribute('hidden', panel.id !== target.getAttribute('aria-controls'));
});
target.focus();
} Automatic vs manual activation
The example above uses automatic activation — moving with arrow keys also selects. That’s right for cheap content (already in the DOM, no network). For panels that fetch on activation, use manual activation: arrow keys move focus only, and the user presses Enter or Space to actually select. The APG covers both — pick based on cost.
Anti-pattern: links pretending to be tabs
Anchor links with active styling. Looks identical, fails the keyboard model.
Profile pretend-content. There’s no role here, no announcement of "tab", no arrow-key navigation.
Notifications pretend-content.
Billing pretend-content.
What’s missing:
- No
role="tab"/role="tablist"/role="tabpanel". Screen readers announce “link”, not “tab, 2 of 3, selected”. The user can’t tell this is a tab interface — they just hear three random links followed by some text. - Every “tab” is in the tab order. Tab cycles through all of them before reaching the panel. With five tabs and a long page, that’s painful.
- Arrow keys do nothing. No
keydownhandler means no APG behaviour. - Activation feels like navigation. Anchors update the URL hash and scroll. That’s not what tabs are supposed to do — selection should feel local.
- No relationship between tabs and panels. Without
aria-controls/aria-labelledby, a screen reader user navigating into the panel has no idea which tab’s content they’re in.
Common variations
- Vertical tabs — same pattern, but the tab list is
flex-direction: columnand arrow keys swap to Up/Down. Setaria-orientation="vertical"on the tablist so AT knows. - Lazy-loaded panels — render the selected panel only, and load others on first activation. Use manual activation in that case so arrow-key browsing doesn’t fire requests.
- Scrollable tab list — when there are too many to fit, let the list scroll. Don’t hide overflow — keep arrow-key navigation functional and bring the focused tab into view (
scrollIntoView({ block: 'nearest' })). - Persisted selection — store the active tab’s ID in
localStorage(or the URL hash, if it should be linkable). On load, restore selection before paint to avoid a flash.
Checklist
- Real
<button>elements withrole="tab" -
<div role="tablist">with an accessible name -
aria-selected="true"on exactly one tab;tabindex="0"on that tab,-1on the rest - Arrow keys move between tabs (Left/Right or Up/Down per orientation)
- Home / End jump to first/last
- Each panel has
role="tabpanel"and is named by its tab viaaria-labelledby - Inactive panels use
hidden(ordisplay: none) — not just visually offscreen - If the panel has no focusable content, give it
tabindex="0"so it’s reachable - Visible focus indicator on tabs (don’t rely on
aria-selectedstyling alone)