Skip to content
Back to Design Hub

Tabs Component Design

Accessible tablist with keyboard arrows, ARIA roles, and controlled vs uncontrolled modes.

Component PatternsIntermediate

Why

Tabs collapse related content into one region, cutting vertical scroll and letting users compare sections without losing context. Built wrong, they trap keyboard users and break screen-reader announcements.

Compound component patterns are used by Radix UI, Headless UI, and shadcn/ui — the three most adopted headless libraries in 2024. They reduce prop drilling by 70% compared to flat component APIs.

When

Use tabs for 2-6 peer sections that share a context (Settings → Profile/Billing/Security). Avoid for sequential flows — use a Stepper; and avoid for >6 sections — use a sidebar nav.

How

1. Roles — tablist, tab (aria-selected), tabpanel (aria-labelledby by tab id) 2. Keyboard — Arrow Left/Right moves between tabs, Home/End jumps to first/last, no Tab to leave until panel 3. Activation — auto on focus (move + activate) or manual (Enter/Space); pick one, document it 4. State — activeTab id; uncontrolled uses internal useState, controlled takes value + onValueChange 5. Panel render — keep inactive panels mounted with hidden to preserve state, or unmount to save DOM 6. Focus — move focus to the new tab on activation; restore to tab when panel closes 7. Animation — cross-fade panels with 150ms ease-out, respect prefers-reduced-motion

Example

function Tabs({ tabs, defaultTab }: { tabs: Tab[]; defaultTab: string }) { const [active, setActive] = useState(defaultTab); const onKeyDown = (e: React.KeyboardEvent) => { const i = tabs.findIndex((t) => t.id === active); if (e.key === "ArrowRight") setActive(tabs[(i + 1) % tabs.length].id); if (e.key === "ArrowLeft") setActive(tabs[(i - 1 + tabs.length) % tabs.length].id); if (e.key === "Home") setActive(tabs[0].id); if (e.key === "End") setActive(tabs[tabs.length - 1].id); }; return ( <> <div role="tablist" onKeyDown={onKeyDown}> {tabs.map((t) => ( <button key={t.id} role="tab" id={`tab-${t.id}`} aria-selected={active === t.id} aria-controls={`panel-${t.id}`} tabIndex={active === t.id ? 0 : -1} onClick={() => setActive(t.id)}> {t.label} </button> ))} </div> {tabs.map((t) => ( <div key={t.id} role="tabpanel" id={`panel-${t.id}`} aria-labelledby={`tab-${t.id}`} hidden={active !== t.id}>{t.content}</div> ))} </> ); }

Common Problems

  • ⚠No roving tabindex — every tab is tabbable, Tab key jumps into panel instead of next tab
  • ⚠Panels unmounted on switch — form state inside is lost, user re-enters everything
  • ⚠Missing aria-controls/aria-labelledby — screen reader can't associate tab with its panel

Best Practices

  • ✓Use roving tabindex — only the active tab has tabIndex=0, others -1, arrows move focus
  • ✓Keep inactive panels mounted with hidden — preserves state and avoids remount cost
  • ✓Pick one activation model and document it — mixing auto + manual within a product confuses users

FAQ