Tabs
Switch between related panels of content within the same context — settings sections, views of a dataset, or steps in a flow. Built on Headless UI's tabs, so keyboard navigation (arrow keys, Home/End), focus, and role="tab"/tabpanel wiring are handled for you.
Tabs is a compound component. Because it's built on Headless UI, the triggers and panels are matched by order — the first trigger controls the first panel, and so on.
Anatomy
Tabs ← the root; sets the default/selected tab
├── TabsList ← the bar containing the triggers
│ └── TabsTrigger ← one per tab (its content is the label)
└── TabsPanels ← wraps the panels
└── TabsContent ← one per tab; shown when its tab is selectedKeep the triggers and panels in the same order — the Nth trigger pairs with the Nth panel.
Usage
<script setup lang="ts">
import {
Tabs,
TabsList,
TabsTrigger,
TabsPanels,
TabsContent,
} from "@dlbcodes/ui";
</script>
<template>
<Tabs>
<TabsList>
<TabsTrigger>Account</TabsTrigger>
<TabsTrigger>Password</TabsTrigger>
<TabsTrigger>Notifications</TabsTrigger>
</TabsList>
<TabsPanels>
<TabsContent>
<p class="text-sm text-text-secondary">
Manage your account details and profile.
</p>
</TabsContent>
<TabsContent>
<p class="text-sm text-text-secondary">
Update your password and security settings.
</p>
</TabsContent>
<TabsContent>
<p class="text-sm text-text-secondary">
Choose what you get notified about.
</p>
</TabsContent>
</TabsPanels>
</Tabs>
</template>
Default tab
Use defaultIndex on Tabs to choose which tab is selected initially (0-based).
<script setup lang="ts">
import {
Tabs,
TabsList,
TabsTrigger,
TabsPanels,
TabsContent,
} from "@dlbcodes/ui";
</script>
<template>
<Tabs :default-index="1">
<TabsList>
<TabsTrigger>Overview</TabsTrigger>
<TabsTrigger>Analytics</TabsTrigger>
<TabsTrigger>Reports</TabsTrigger>
</TabsList>
<TabsPanels>
<TabsContent
><p class="text-sm text-text-secondary">
Overview content.
</p></TabsContent
>
<TabsContent
><p class="text-sm text-text-secondary">
Analytics content — selected by default.
</p></TabsContent
>
<TabsContent
><p class="text-sm text-text-secondary">
Reports content.
</p></TabsContent
>
</TabsPanels>
</Tabs>
</template>
With icons
A trigger's content is slot content, so you can include an icon alongside the label.
<script setup lang="ts">
import {
Tabs,
TabsList,
TabsTrigger,
TabsPanels,
TabsContent,
} from "@dlbcodes/ui";
import { PhUser, PhLock, PhBell } from "@phosphor-icons/vue";
</script>
<template>
<Tabs>
<TabsList>
<TabsTrigger>
<PhUser class="size-4" />
Account
</TabsTrigger>
<TabsTrigger>
<PhLock class="size-4" />
Security
</TabsTrigger>
<TabsTrigger>
<PhBell class="size-4" />
Alerts
</TabsTrigger>
</TabsList>
<TabsPanels>
<TabsContent
><p class="text-sm text-text-secondary">
Account panel.
</p></TabsContent
>
<TabsContent
><p class="text-sm text-text-secondary">
Security panel.
</p></TabsContent
>
<TabsContent
><p class="text-sm text-text-secondary">
Alerts panel.
</p></TabsContent
>
</TabsPanels>
</Tabs>
</template>
Disabled tab
Mark a trigger disabled to make it non-selectable — it's skipped by keyboard navigation.
<script setup lang="ts">
import {
Tabs,
TabsList,
TabsTrigger,
TabsPanels,
TabsContent,
} from "@dlbcodes/ui";
</script>
<template>
<Tabs>
<TabsList>
<TabsTrigger>Active</TabsTrigger>
<TabsTrigger :disabled="true">Disabled</TabsTrigger>
<TabsTrigger>Another</TabsTrigger>
</TabsList>
<TabsPanels>
<TabsContent
><p class="text-sm text-text-secondary">
First panel.
</p></TabsContent
>
<TabsContent
><p class="text-sm text-text-secondary">
You can't reach this via the disabled tab.
</p></TabsContent
>
<TabsContent
><p class="text-sm text-text-secondary">
Third panel.
</p></TabsContent
>
</TabsPanels>
</Tabs>
</template>
Controlled
For full control, bind selectedIndex and listen for change instead of using defaultIndex:
<Tabs :selected-index="active" @change="active = $event">
<!-- … -->
</Tabs>Use manual on Tabs if you'd rather a tab activate only on Enter/Space (not as soon as it's focused) — useful when switching tabs triggers expensive work.
Appearance
The bar is a --muted track with a --border outline, and the selected tab is lifted above it: white in the light theme, a lighter grey in dark mode. Unselected tabs use --muted-foreground and darken on hover. Keyboard focus shows a ring in the --ring color. See Theming.
The corners follow the theme's --radius, so the bar and its tabs change shape with your radius setting: square at 0, softly rounded at the default. The tab is one step smaller than the bar so the two stay nested cleanly. If you'd like the classic pill shape, round both parts yourself:
<TabsList class="rounded-full">
<TabsTrigger class="rounded-full">Account</TabsTrigger>
<TabsTrigger class="rounded-full">Password</TabsTrigger>
</TabsList>Props
Tabs
| Prop | Type | Default | Description |
|---|---|---|---|
defaultIndex | number | 0 | Initially selected tab (uncontrolled), 0-based. |
selectedIndex | number | — | Controlled selected tab. Pair with @change. |
manual | boolean | false | Activate a tab only on Enter/Space rather than on focus. |
class | string | — | Classes merged onto the root. |
Emits change with the new index when the selected tab changes.
TabsTrigger
| Prop | Type | Default | Description |
|---|---|---|---|
disabled | boolean | false | Disables the tab — skipped by keyboard navigation. |
class | string | — | Classes merged onto the trigger. |
Its slot content is the label, and it exposes selected (boolean) via the slot.
TabsList / TabsPanels / TabsContent
Each takes only class. TabsList is the bar, TabsPanels wraps the panels, and each TabsContent is one panel.
Accessibility
- Built on Headless UI's tabs, so arrow-key navigation,
Home/End, focus management, androle="tablist"/tab/tabpanelwiring are handled. - Disabled tabs are skipped during keyboard navigation.
- The selected state is exposed via the
selectedslot prop and styled through the tab variants.