---
name: tabs
type: core
domain: navigation
requires: [loke-ui]
description: >
  Tabbed interfaces with Tabs/TabsList/TabsTrigger/TabsContent composition.
  Value-based trigger-to-content linking via matching value props. Roving focus
  via RovingFocusGroup. activationMode automatic (focus selects) vs manual
  (Enter/Space selects). forceMount on TabsContent for exit animations via
  Presence. orientation horizontal (default) or vertical.
---

# Tabs

## Setup

Basic tabs with a list, triggers, and content panels.

```tsx
import {
  Tabs,
  TabsList,
  TabsTrigger,
  TabsContent,
} from "@loke/ui/tabs";

function ProfileTabs() {
  return (
    <Tabs defaultValue="account">
      <TabsList>
        <TabsTrigger value="account">Account</TabsTrigger>
        <TabsTrigger value="password">Password</TabsTrigger>
        <TabsTrigger value="notifications">Notifications</TabsTrigger>
      </TabsList>

      <TabsContent value="account">
        <p>Manage your account settings.</p>
      </TabsContent>

      <TabsContent value="password">
        <p>Change your password here.</p>
      </TabsContent>

      <TabsContent value="notifications">
        <p>Configure notification preferences.</p>
      </TabsContent>
    </Tabs>
  );
}
```

## Core Patterns

### Manual activation

By default (`activationMode="automatic"`), focusing a tab via arrow keys immediately selects it and shows its panel. In manual mode, focus and selection are separate — the user must press Enter or Space to activate the focused tab.

Use manual mode when switching tabs triggers expensive operations (network requests, heavy rendering).

```tsx
<Tabs defaultValue="overview" activationMode="manual">
  <TabsList>
    <TabsTrigger value="overview">Overview</TabsTrigger>
    <TabsTrigger value="analytics">Analytics</TabsTrigger>
    <TabsTrigger value="reports">Reports</TabsTrigger>
  </TabsList>

  <TabsContent value="overview">Overview panel</TabsContent>
  <TabsContent value="analytics">Analytics panel</TabsContent>
  <TabsContent value="reports">Reports panel</TabsContent>
</Tabs>
```

Source: `src/components/tabs/tabs.tsx` — `activationMode !== "manual"` check in `onFocus`.

### Vertical tabs

Sets `orientation="vertical"` so arrow navigation uses ArrowUp/ArrowDown instead of ArrowLeft/ArrowRight.

```tsx
<Tabs defaultValue="general" orientation="vertical">
  <TabsList>
    <TabsTrigger value="general">General</TabsTrigger>
    <TabsTrigger value="security">Security</TabsTrigger>
    <TabsTrigger value="billing">Billing</TabsTrigger>
  </TabsList>

  <TabsContent value="general">General settings</TabsContent>
  <TabsContent value="security">Security settings</TabsContent>
  <TabsContent value="billing">Billing settings</TabsContent>
</Tabs>
```

Style with CSS using `data-orientation="vertical"` on the root `Tabs` element.

### Animated panels with `forceMount`

Without `forceMount`, `TabsContent` unmounts immediately when another tab is selected, preventing CSS exit animations. With `forceMount`, the panel stays in the DOM and `data-state` toggles between `"active"` and `"inactive"` — CSS animations can target these.

```tsx
<Tabs defaultValue="a">
  <TabsList>
    <TabsTrigger value="a">Tab A</TabsTrigger>
    <TabsTrigger value="b">Tab B</TabsTrigger>
  </TabsList>

  <TabsContent value="a" forceMount>
    Panel A
  </TabsContent>

  <TabsContent value="b" forceMount>
    Panel B
  </TabsContent>
</Tabs>
```

```css
/* TabsContent starts hidden via hidden attribute when inactive */
[role="tabpanel"][data-state="active"] {
  animation: tab-in 150ms ease-out;
}

@keyframes tab-in {
  from { opacity: 0; transform: translateY(4px); }
  to   { opacity: 1; transform: translateY(0); }
}
```

Note: `TabsContent` sets `hidden` on the DOM element when inactive (even with `forceMount`) and only renders `children` when `present`. The `animationDuration` is forced to `"0s"` on initial mount to prevent the active tab from animating in on first render.

Source: `src/components/tabs/tabs.tsx` — `Presence` + `isMountAnimationPreventedRef`.

### Controlled state

```tsx
const [tab, setTab] = useState("account");

<Tabs value={tab} onValueChange={setTab}>
  <TabsList>
    <TabsTrigger value="account">Account</TabsTrigger>
    <TabsTrigger value="password">Password</TabsTrigger>
  </TabsList>
  <TabsContent value="account">Account panel</TabsContent>
  <TabsContent value="password">Password panel</TabsContent>
</Tabs>
```

## Common Mistakes

### Mismatched `value` props between trigger and content — panel never shows

`TabsTrigger` and `TabsContent` are linked by an exact string match on `value`. A mismatch means the content panel is never marked active.

```tsx
// Wrong — values don't match
<TabsTrigger value="acct">Account</TabsTrigger>
<TabsContent value="account">...</TabsContent>

// Correct
<TabsTrigger value="account">Account</TabsTrigger>
<TabsContent value="account">...</TabsContent>
```

Source: `src/components/tabs/tabs.tsx` — `makeTriggerId`/`makeContentId` generate IDs from value; `isSelected = value === context.value`.

### Not understanding automatic vs manual activation — unexpected UX on keyboard nav

In `automatic` mode (the default), pressing ArrowRight focuses the next tab **and immediately selects it**, showing its panel. Users accustomed to arrow-then-Enter workflows will trigger panels unintentionally. Use `activationMode="manual"` when tab selection has side effects.

Source: `src/components/tabs/tabs.tsx` — `onFocus` fires `onValueChange` when `activationMode !== "manual"`.

### `TabsContent` outside `Tabs` root — context error at runtime

`TabsContent` must be a descendant of `Tabs`. It reads context for `value`, `baseId`, and `orientation`. Rendering it outside throws a context missing error.

```tsx
// Wrong
<Tabs defaultValue="a">
  <TabsList>...</TabsList>
</Tabs>
<TabsContent value="a">...</TabsContent>  {/* outside Tabs — throws */}

// Correct
<Tabs defaultValue="a">
  <TabsList>...</TabsList>
  <TabsContent value="a">...</TabsContent>
</Tabs>
```

Source: `src/components/tabs/tabs.tsx` — `useTabsContext` throws if no provider found.

## Cross-references

- **Accordion** (`@loke/ui/accordion`) — for expandable sections where multiple panels can be visible simultaneously
- **Choosing the Right Component** — Tabs vs Accordion decision guidance
