Tabs
A tabbed interface with accessible keyboard navigation. Built on Radix UI Tabs.
Account
Update your account details. Changes save when you click the button.
Installation
pnpm dlx @hex-core/cli add tabsVertical orientation
Stacks tabs in a left-side rail. Arrow-up/down navigate; arrow-left/right do nothing in this orientation.
<Tabs defaultValue="profile" orientation="vertical" className="flex gap-4">
<TabsList className="flex h-auto flex-col items-stretch">
<TabsTrigger value="profile" className="justify-start">Profile</TabsTrigger>
<TabsTrigger value="billing" className="justify-start">Billing</TabsTrigger>
<TabsTrigger value="team" className="justify-start">Team</TabsTrigger>
</TabsList>
<div className="flex-1">
<TabsContent value="profile">Profile pane.</TabsContent>
<TabsContent value="billing">Billing pane.</TabsContent>
<TabsContent value="team">Team pane.</TabsContent>
</div>
</Tabs>Disabled tab
Use `disabled` on a `TabsTrigger` when a section isn't available yet (feature behind a flag, premium-only, etc.). Disabled triggers are skipped in keyboard navigation.
<Tabs defaultValue="current">
<TabsList>
<TabsTrigger value="current">Current plan</TabsTrigger>
<TabsTrigger value="upgrade">Upgrade</TabsTrigger>
<TabsTrigger value="enterprise" disabled>
Enterprise (contact sales)
</TabsTrigger>
</TabsList>
<TabsContent value="current">You're on the Pro plan.</TabsContent>
<TabsContent value="upgrade">Compare plans here.</TabsContent>
<TabsContent value="enterprise">—</TabsContent>
</Tabs>Controlled selection
Hold the active tab in parent state when you need to drive it from outside (deep linking, persisting last-viewed tab, syncing across panes). Pair `value` with `onValueChange`.
function ControlledTabs() {
const [tab, setTab] = useState("overview");
return (
<Tabs value={tab} onValueChange={setTab}>
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
</TabsList>
<TabsContent value="overview">Active: {tab}</TabsContent>
<TabsContent value="activity">Active: {tab}</TabsContent>
</Tabs>
);
}Manual activation
When switching panels triggers expensive work (network fetch, large render), use `activationMode="manual"` so users arrow through triggers without committing. Selection requires Enter/Space.
<Tabs defaultValue="daily" activationMode="manual">
<TabsList>
<TabsTrigger value="daily">Daily</TabsTrigger>
<TabsTrigger value="weekly">Weekly</TabsTrigger>
<TabsTrigger value="monthly">Monthly</TabsTrigger>
</TabsList>
<TabsContent value="daily">Daily report (heavy query).</TabsContent>
<TabsContent value="weekly">Weekly report (heavy query).</TabsContent>
<TabsContent value="monthly">Monthly report (heavy query).</TabsContent>
</Tabs>API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
defaultValue | string | — | Default active tab value (uncontrolled) |
value | string | — | Controlled active tab value. Pair with `onValueChange` to manage selection in parent state. |
onValueChange | function | — | Callback: `(value: string) => void`. Fires after the user selects a tab. |
orientation | "horizontal" | "vertical" | horizontal | Layout axis. `horizontal` stacks tabs left-to-right above content; `vertical` stacks them top-to-bottom in a sidebar configuration. Affects keyboard navigation (arrow-up/down vs left/right). |
dir | "ltr" | "rtl" | — | Reading direction. Defaults to inherited locale direction; pass `rtl` explicitly when nesting in a left-to-right parent that should still flip this widget. |
activationMode | "automatic" | "manual" | automatic | How tabs activate. `automatic` switches the panel as the user arrows through (read-on-hover semantics); `manual` requires Enter/Space to commit the selection — use this when the panel transition is expensive. |
className | string | — | Additional CSS classes merged onto the root element. |
AI Guidance
When to use
Use to organize content into switchable panels: settings pages, dashboards, product details with multiple sections.
When not to use
Don't use for navigation between pages (use router/links). Don't use for steppers/wizards (use a stepper component).
Common mistakes
- Missing `defaultValue` (or `value`) — no tab is selected initially and the first panel renders empty
- `TabsTrigger value` not matching `TabsContent value` — the panel never opens because Radix matches them as strings
- Using Tabs for page navigation — use Next.js routing + `<Link>` instead; Tabs is for in-page content switching only
- Forgetting to switch to `activationMode="manual"` when each panel kicks off a heavy network fetch — automatic activation will fire fetches as the user arrows past every tab
- Mixing controlled (`value`) and uncontrolled (`defaultValue`) on the same Tabs root — pick one
Accessibility
Full keyboard navigation is built-in: arrow-left/right (or up/down in vertical orientation) cycle triggers, Home/End jump to the first/last, Enter/Space activate when `activationMode="manual"`. Radix wires `role="tablist"`, `role="tab"`, `role="tabpanel"`, `aria-selected`, and `aria-controls` automatically. When using a vertical layout, set `orientation="vertical"` on the root so screen readers announce the orientation correctly and arrow-key handling matches the visual axis.
Token budget: 700
Verified against @hex-core/components@1.12.0