OperantAI Workflow Design System
ComfortableCompact

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

PropTypeDefaultDescription
valuestring—Controlled active tab value.
defaultValuestring—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.

PropTypeDefaultDescription
valuestring—Required. Must match a TabsContent's value.
disabledbooleanfalseRemoves 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).

PropTypeDefaultDescription
valuestring—Required. Must match a TabsTrigger's value.
forceMountboolean—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", and aria-selected/aria-controls/aria-labelledby wired automatically.
  • Arrow-key navigation follows orientation (ArrowLeft/ArrowRight for horizontal, ArrowUp/ArrowDown for vertical) — matching AGENTS.md #8's keyboard requirement ("tabs (arrows)").
  • Disabled tabs (disabled prop → real disabled attribute on the <button role="tab">) are skipped during arrow-key traversal, not just visually dimmed.
  • Give TabsList an aria-label (or aria-labelledby) describing what the tab set switches between, e.g. "Run detail views".
  • Because TabsContent renders no visual class, remember to apply your own spacing via className/style — it's a bare, unmounted-when-inactive panel by default.