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.json

Usage

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

  1. 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 a role="tabpanel" that mounts only while its value matches the active Trigger.
  2. Controlled and uncontrolled. Pass value for fully controlled state, or defaultValue for uncontrolled. The component picks the mode from whichever prop is defined on mount, the React tradition for value / defaultValue pairs. onValueChange fires in either mode.
  3. 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 matching aria-orientation is set on the tablist so assistive tech announces the layout.
  4. Roving focus. Only the active Trigger participates in the sequential focus order (tabIndex={0}); inactive Triggers carry tabIndex={-1}. Arrow keys move focus and commit the new value, the canonical WAI-ARIA tabs pattern. Home / End jump to the first / last Trigger.
  5. 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.
  6. ARIA wiring. Each Trigger carries aria-selected + aria-controls pointing at its panel; each Content carries aria-labelledby back at its Trigger. The ids are derived from useId so they stay stable and unique across multiple Tabs in the same page.
  7. Animated reveal. Each Content uses Framer Motion AnimatePresence with mode="wait" so the previous panel exits before the next enters. Opacity + a 4 px vertical slide run through SPRINGS.smooth so timing matches the rest of the library.

Props

Tabs.Root

PropTypeDefaultDescription
valuestringControlled active value. Pair with onValueChange.
defaultValuestringInitial active value — uncontrolled mode only.
onValueChange(value: string) => voidFires whenever the active value changes, in either mode.
orientation"horizontal" | "vertical""horizontal"Layout axis. Drives ARIA orientation + arrow-key mapping.
classNamestringMerged onto the rendered <div> container.
...restHTMLAttributes<HTMLDivElement>Any other <div> prop.

Tabs.List

PropTypeDefaultDescription
classNamestringMerged onto the rendered <div role="tablist">.
onKeyDownevent handlerRuns before the arrow-key handler; call event.preventDefault() to suppress.
...restHTMLAttributes<HTMLDivElement>Any other <div> prop.

Tabs.Trigger

PropTypeDefaultDescription
valuestringRequired. Matched against Tabs.Content's value to pair Trigger and panel.
classNamestringMerged onto the rendered <button>.
onClickevent handlerRuns before the value commits; call event.preventDefault() to suppress.
...restButtonHTMLAttributes<HTMLButtonElement>Any other <button> prop.

Tabs.Content

PropTypeDefaultDescription
valuestringRequired. Matched against Tabs.Trigger's value to pair panel and Trigger.
classNamestringMerged onto the rendered <div role="tabpanel">.
...restHTMLAttributes<HTMLDivElement>Any other <div> prop.

Accessibility

  • The List carries role="tablist" with the matching aria-orientation, so assistive tech announces both the group and its layout axis.
  • Each Trigger is a real <button type="button" role="tab"> with aria-selected tracking the active state and aria-controls pointing 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-labelledby back at its Trigger so screen readers read the panel with the Trigger's label.
  • Focus rings use --cb-accent with 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-motion still 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 over radix-ui's Tabs primitive with two cva variants (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.