Tabs switches between multiple views within one context (e.g. a run's Overview / Trace / Citations panels). Per the AGENTS.md decision table, use Tabs for "switch views in one context" — for sequential workflow progress, use Stepper instead.
Installation
HTML — already included if you loaded the 3 stylesheets — see the Installation guide.
React
npm install @operant/ui
@operant/ui is currently a monorepo workspace package, not yet published to the public npm registry.
Usage
HTML
Plain HTML has no built-in roving-tabindex or panel-switching behavior — you need a small amount of JavaScript to toggle .is-active / data-state and show/hide panels. The markup convention:
<div class="ods-tabs" role="tablist" aria-label="Run detail views">
<button class="ods-tab is-active" role="tab" aria-selected="true" id="tab-overview">Overview</button>
<button class="ods-tab" role="tab" aria-selected="false" id="tab-trace">Trace</button>
<button class="ods-tab" role="tab" aria-selected="false" id="tab-citations">Citations</button>
</div>
<div role="tabpanel" aria-labelledby="tab-overview">
Run completed in 4.2s across 3 tool calls.
</div>
React
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@operant/ui";
export default function Example() {
return (
<Tabs defaultValue="overview">
<TabsList aria-label="Run detail views">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="trace">Trace</TabsTrigger>
</TabsList>
<TabsContent value="overview">Run completed in 4.2s across 3 tool calls.</TabsContent>
<TabsContent value="trace">
<code>fetch_incidents → summarize → cite_sources</code>
</TabsContent>
</Tabs>
);
}
Examples
Run detail (3 tabs)
The active tab gets a --color-primary bottom border and full-contrast text; inactive tabs are --color-text-muted and brighten to --color-text on hover.
HTML
<div class="ods-tabs" role="tablist" aria-label="Run detail views">
<button class="ods-tab is-active" role="tab" aria-selected="true">Overview</button>
<button class="ods-tab" role="tab" aria-selected="false">Trace</button>
<button class="ods-tab" role="tab" aria-selected="false">Citations</button>
</div>
<div role="tabpanel">Run completed in 4.2s across 3 tool calls.</div>
React
<Tabs defaultValue="overview" style={{ width: 480 }}>
<TabsList aria-label="Run detail views">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="trace">Trace</TabsTrigger>
<TabsTrigger value="citations">Citations</TabsTrigger>
</TabsList>
<TabsContent value="overview" style={{ padding: "var(--space-8) 0" }}>
Run completed in 4.2s across 3 tool calls.
</TabsContent>
<TabsContent value="trace" style={{ padding: "var(--space-8) 0" }}>
<code>fetch_incidents → summarize → cite_sources</code>
</TabsContent>
<TabsContent value="citations" style={{ padding: "var(--space-8) 0" }}>
3 sources cited: runbook-142, incident-9981, on-call-notes.
</TabsContent>
</Tabs>
Disabled tab
A tab can be individually disabled (e.g. content still indexing) while the rest of the tab list stays interactive.
HTML
<div class="ods-tabs" role="tablist" aria-label="Run detail views">
<button class="ods-tab is-active" role="tab" aria-selected="true">Overview</button>
<button class="ods-tab" role="tab" aria-selected="false" disabled aria-disabled="true">Trace (unavailable)</button>
<button class="ods-tab" role="tab" aria-selected="false">Citations</button>
</div>
React
<Tabs defaultValue="overview" style={{ width: 480 }}>
<TabsList aria-label="Run detail views">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="trace" disabled>
Trace (unavailable)
</TabsTrigger>
<TabsTrigger value="citations">Citations</TabsTrigger>
</TabsList>
<TabsContent value="overview" style={{ padding: "var(--space-8) 0" }}>
Run completed in 4.2s across 3 tool calls.
</TabsContent>
<TabsContent value="trace" style={{ padding: "var(--space-8) 0" }}>
Trace is still being indexed.
</TabsContent>
<TabsContent value="citations" style={{ padding: "var(--space-8) 0" }}>
3 sources cited: runbook-142, incident-9981, on-call-notes.
</TabsContent>
</Tabs>
API Reference
<Tabs>
Direct re-export of Radix TabsPrimitive.Root — no .ods-* class of its own (owns selection state only).
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Controlled active tab value. |
defaultValue | string | — | Uncontrolled initial active tab. |
onValueChange | (value: string) => void | — | Called when the active tab changes. |
orientation | "horizontal" | "vertical" | "horizontal" | Arrow-key navigation axis. |
activationMode | "automatic" | "manual" | "automatic" | "automatic" selects a tab on arrow-key focus; "manual" requires an explicit Enter/Space. |
<TabsList>
Renders .ods-tabs (flex row with a bottom hairline border). Sets Radix role="tablist" automatically.
<TabsTrigger>
Renders .ods-tab. Radix toggles data-state="active" | "inactive" on it directly.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Required. Must match a TabsContent's value. |
disabled | boolean | false | Removes this tab from keyboard navigation and click activation. |
<TabsContent>
Pass-through panel with no .ods-* class applied — css/components.css has no dedicated tab-panel class, so className composes through untouched for page-specific spacing (see the style/padding used in the examples above).
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Required. Must match a TabsTrigger's value. |
forceMount | boolean | — | Keeps inactive panels mounted (hidden) instead of unmounting them. |
HTML-side classes: .ods-tabs (row container, bottom hairline border), .ods-tab (.is-active or [data-state="active"] — css/components.css keys off both selectors, so triggers driven by the React component's Radix data-state attribute render identically to hand-written .is-active markup).
Accessibility
- Radix implements the full WAI-ARIA Tabs pattern: roving tabindex (only the active tab is a tab-stop; arrow keys move focus between tabs),
role="tablist"/"tab"/"tabpanel", andaria-selected/aria-controls/aria-labelledbywired automatically. - Arrow-key navigation follows
orientation(ArrowLeft/ArrowRightfor horizontal,ArrowUp/ArrowDownfor vertical) — matchingAGENTS.md#8's keyboard requirement ("tabs (arrows)"). - Disabled tabs (
disabledprop → realdisabledattribute on the<button role="tab">) are skipped during arrow-key traversal, not just visually dimmed. - Give
TabsListanaria-label(oraria-labelledby) describing what the tab set switches between, e.g."Run detail views". - Because
TabsContentrenders no visual class, remember to apply your own spacing viaclassName/style— it's a bare, unmounted-when-inactive panel by default.