@loke/ui
Version:
197 lines (148 loc) • 6.37 kB
Markdown
---
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