Accessible tabs
A tab list that handles keyboard navigation, ARIA state, and focus management the way the ARIA Authoring Practices Guide specifies. The pattern AI tools get wrong by inventing keyboard handlers from scratch.
Why this pattern keeps breaking
Tabs are the second-most-common custom widget after modals, and the second-most-common to fail accessibility review. The visual pattern is obvious — a row of headers, a panel below, the active one highlighted. The behavioural contract is what gets dropped: the tab list has to be a single tab stop, arrow keys move between tabs, Tab moves into the panel, the right ARIA roles and state attributes have to be wired up, and the active tab has to be visually distinguishable in a way that doesn't rely on colour alone.
How AI coding tools fail this
Ask any AI assistant for "a tabs component" and you'll get a row of
<button> elements that toggle a visible panel. The visual is
correct. The keyboard contract is almost always missing in one of
three ways:
- Every tab is in the natural tab order, so Tab cycles through every tab instead of moving past the tablist into the panel. WCAG 2.1.1 passes; usability fails.
- Arrow keys do nothing. The ARIA APG requires Left/Right (or Up/Down for vertical tabs) to move focus between tabs without leaving the tablist. Without that, the widget doesn't behave like the user expects from native tabs anywhere else.
aria-selectedis set on the active tab but not unset on the others, or vice versa. The screen reader hears "selected" or "not selected" in inconsistent places and stops being able to tell what state the widget is in.
A subtler one: the active tab's only visual distinction is a colour change that fails 3:1 contrast against the inactive tabs. Sighted users with low vision can't tell which tab is open.
Canonical implementation
The right answer for most product teams: use a vetted library. Radix UI Tabs (opens in new tab), Headless UI Tab (opens in new tab), and React Aria Tabs (opens in new tab) all ship the keyboard contract, focus management, and ARIA attributes correctly out of the box.
A minimal Radix-based tabs setup that meets the contract:
import * as Tabs from "@radix-ui/react-tabs";
export function ProjectTabs() {
return (
<Tabs.Root defaultValue="overview">
<Tabs.List aria-label="Project sections" className="flex border-b">
<Tabs.Trigger value="overview" className="px-4 py-2 data-[state=active]:border-b-2 data-[state=active]:border-primary">
Overview
</Tabs.Trigger>
<Tabs.Trigger value="activity" className="px-4 py-2 data-[state=active]:border-b-2 data-[state=active]:border-primary">
Activity
</Tabs.Trigger>
<Tabs.Trigger value="settings" className="px-4 py-2 data-[state=active]:border-b-2 data-[state=active]:border-primary">
Settings
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="overview" className="pt-6">Overview panel.</Tabs.Content>
<Tabs.Content value="activity" className="pt-6">Activity panel.</Tabs.Content>
<Tabs.Content value="settings" className="pt-6">Settings panel.</Tabs.Content>
</Tabs.Root>
);
}This satisfies the contract automatically: arrow keys move between
tabs, Home and End jump to the first and last, Tab moves into the
panel, the active tab gets aria-selected="true" and a 2px border
that meets 3:1 contrast, and the inactive tabs report
aria-selected="false".
Verification checklist
Before shipping any tab component, manually verify each of these:
- Tab into the tab list — focus lands on the active tab only, not the first tab in the list.
- Press Right arrow — focus moves to the next tab; the previous tab is no longer in the tab order.
- Press Left arrow at the first tab — focus wraps to the last.
- Press Home — focus moves to the first tab.
- Press End — focus moves to the last tab.
- Press Tab from any active tab — focus moves into the panel, not to the next tab.
- With a screen reader running, open the tabs — the active tab is announced as selected, inactive tabs as not selected.
- Inspect the active-state border or background — it meets 3:1 contrast against the inactive-state colour.