Tabs
An in-page switcher between co-equal panels. Tabs owns the switch; the app owns the content. It is not navigation, and it is not a segmented control — a labelled switch covers two co-equal options, and select covers three or more with no panels.
One variant, one size
Underline only, horizontal only, one size matching button default. Four exports — Tabs, TabsList, TabsTrigger, TabsContent — and every prop is a Radix pass-through: no variant, no size, no orientation. The sliding indicator lives inside TabsTrigger itself, never exported, so a consumer can never place it wrong.
Automatic activation
The default. Tab lands on the active trigger; Left and Right move focus and select at once. Safe because panels are local content that does not fetch on mount. Try it: focus the list below and use the arrow keys.
Manual activation
Pass activationMode="manual" when a panel is expensive to mount. Arrow keys move focus without selecting; Enter or Space selects the focused trigger. While focus and selection differ, a 1px --ring bar appears under the focused trigger — the only case where it shows. Try it: focus the list, arrow to a different trigger, and note the thin bar before pressing Enter.
States and disabled
Inactive labels are --muted-foreground; active labels are --foreground under the 2px --indicator bar. Hover steps an inactive label to --foreground in CSS at --motion-fast, with no fill and no border change — a click moves the indicator immediately, so a press ring would fire and be overtaken by the slide, and there is none. A disabled trigger dims to half opacity, takes no pointer events, and is skipped by the arrow keys, shown above as “Billing”.
Panel mounting
Panels unmount by default when inactive. Pass forceMount per panel to keep one panel’s state — scroll position, a half-typed field — alive across a switch; a force-mounted inactive panel is hidden from the accessibility tree and the tab order rather than removed from the DOM. The default costs that state on every switch — the consumer hoists it or sets forceMount for the panels that need it.
Motion
The active indicator is one shared motion.span with a layoutId, rendered only inside the active trigger and animated with layout on spring-bounce. The focus bar is a second, independent layoutId group on the same preset, so the two never interfere. The label color crossfades separately in CSS at --motion-fast. Panels carry no animation at all — automatic activation can replay a switch on every arrow key across a long tab set, and animating that would read as flicker.
Accessibility
Tabs shows focus with no ring — its own state indicator moves with focus instead. Under automatic activation, focus and selection always coincide, so the moving 2px indicator bar is the focus feedback; under manual activation, the 1px ring bar is the only cue. Measured with a real OKLCH-to-sRGB contrast check: the light inactive label is 4.73:1, the light active bar 6.44:1, and the light focus bar 4.58:1; the dark inactive label is 7.63:1, the dark active bar 6.34:1, and the dark focus bar 4.32:1 — every pair clears WCAG AA (4.5:1 text, 3:1 non-text) in both modes with no fallback needed.
Home and End move focus to the first and last trigger. Confirmed at build against Radix’s roving focus group and pinned by a test — this was an open question in the resolved spec.