Tabs
Switches between sibling panels via a strip of Triggers. Composed as a Radix-style compound — Root, List, Trigger, Content. Drives arrow-key roving focus over the Triggers, ARIA-correct role="tablist" / role="tab" / role="tabpanel" wiring, and a smooth panel crossfade.
Preview
Account
Edit your profile, email, and display name.
Installation
npx shadcn@latest add https://craftbits.dev/r/tabs.jsonUsage
Tabs is a compound — Root owns the active-value state machine, the stable ARIA ids, and the orientation; List is the row (or column) of clickable Triggers; each Trigger flips to its matching Content panel.
import { Tabs } from "@craft-bits/core";
<Tabs.Root defaultValue="account">
<Tabs.List>
<Tabs.Trigger value="account">Account</Tabs.Trigger>
<Tabs.Trigger value="password">Password</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="account">Edit your account here.</Tabs.Content>
<Tabs.Content value="password">Change your password here.</Tabs.Content>
</Tabs.Root>Take control of the active value with value + onValueChange:
"use client";
import { useState } from "react";
import { Tabs } from "@craft-bits/core";
const [tab, setTab] = useState("account");
<Tabs.Root value={tab} onValueChange={setTab}>
{/* ... */}
</Tabs.Root>Understanding the component
- Compound parts. Root owns the active value, the stable id prefix, the orientation, and the Trigger registry; List is the
role="tablist"container that binds arrow / Home / End keys to roving focus; each Trigger is a real<button type="button" role="tab">so keyboard users get Enter / Space activation for free; each Content is arole="tabpanel"that mounts only while itsvaluematches the active Trigger. - Controlled and uncontrolled. Pass
valuefor fully controlled state, ordefaultValuefor uncontrolled. The component picks the mode from whichever prop is defined on mount, the React tradition forvalue/defaultValuepairs.onValueChangefires in either mode. - Orientation drives layout + keys.
orientation="horizontal"(default) renders the List as a row and binds ArrowLeft / ArrowRight to roving focus;orientation="vertical"renders the List as a column and binds ArrowUp / ArrowDown. The matchingaria-orientationis set on the tablist so assistive tech announces the layout. - Roving focus. Only the active Trigger participates in the sequential focus order (
tabIndex={0}); inactive Triggers carrytabIndex={-1}. Arrow keys move focus and commit the new value, the canonical WAI-ARIA tabs pattern. Home / End jump to the first / last Trigger. - Trigger registry. Triggers register their
value+ DOM ref with Root on mount. The ordered list is rebuilt from the live registry so unmounts shrink the focus loop — no stale entries. - ARIA wiring. Each Trigger carries
aria-selected+aria-controlspointing at its panel; each Content carriesaria-labelledbyback at its Trigger. The ids are derived fromuseIdso they stay stable and unique across multiple Tabs in the same page. - Animated reveal. Each Content uses Framer Motion
AnimatePresencewithmode="wait"so the previous panel exits before the next enters. Opacity + a 4 px vertical slide run throughSPRINGS.smoothso timing matches the rest of the library.
Props
Tabs.Root
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Controlled active value. Pair with onValueChange. |
defaultValue | string | — | Initial active value — uncontrolled mode only. |
onValueChange | (value: string) => void | — | Fires whenever the active value changes, in either mode. |
orientation | "horizontal" | "vertical" | "horizontal" | Layout axis. Drives ARIA orientation + arrow-key mapping. |
className | string | — | Merged onto the rendered <div> container. |
...rest | HTMLAttributes<HTMLDivElement> | — | Any other <div> prop. |
Tabs.List
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Merged onto the rendered <div role="tablist">. |
onKeyDown | event handler | — | Runs before the arrow-key handler; call event.preventDefault() to suppress. |
...rest | HTMLAttributes<HTMLDivElement> | — | Any other <div> prop. |
Tabs.Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Required. Matched against Tabs.Content's value to pair Trigger and panel. |
className | string | — | Merged onto the rendered <button>. |
onClick | event handler | — | Runs before the value commits; call event.preventDefault() to suppress. |
...rest | ButtonHTMLAttributes<HTMLButtonElement> | — | Any other <button> prop. |
Tabs.Content
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Required. Matched against Tabs.Trigger's value to pair panel and Trigger. |
className | string | — | Merged onto the rendered <div role="tabpanel">. |
...rest | HTMLAttributes<HTMLDivElement> | — | Any other <div> prop. |
Accessibility
- The List carries
role="tablist"with the matchingaria-orientation, so assistive tech announces both the group and its layout axis. - Each Trigger is a real
<button type="button" role="tab">witharia-selectedtracking the active state andaria-controlspointing at its panel. Keyboard activation (Enter / Space) lands on the button for free. - Roving focus follows the canonical WAI-ARIA tabs pattern — only the active Trigger has
tabIndex={0}; arrow keys move focus and activate. Home / End jump to the first / last Trigger. - Each Content carries
role="tabpanel"+aria-labelledbyback at its Trigger so screen readers read the panel with the Trigger's label. - Focus rings use
--cb-accentwith an inset offset on both Triggers and panels so keyboard focus is always visible. - The crossfade is short, uses opacity + a 4 px vertical slide, and animates on the compositor. Users with
prefers-reduced-motionstill get the active / inactive state — only the spring is muted, never the affordance.
Credits
- Extracted from:
algoflashcards(src/platform/ui/tabs.tsx). The original was a thin wrapper overradix-ui's Tabs primitive with twocvavariants (default/line). The craft-bits version drops the runtime dependency on radix-tabs and rebuilds the active-value state machine, Trigger registry, arrow-key roving focus, and ARIA wiring in plain React so the component ships without an extra peer.