Tabs
Switches between panels of content, or between routes. Compose TabList, TabTrigger, and TabPanel inside the Tabs root. The app owns the layout and styles the parts directly.
Use Tabs when the UI switches visible panels or routes (tablist semantics). Use TabButtons when the UI picks a value for a filter, a setting, or a form field (radiogroup semantics). The two are pixel-identical at the same variant and size.
Playground
<Tabs
v-model="tab"
:tabs="[
{ value: 'overview', label: 'Overview', iconLeft: 'lucide-layout-dashboard' },
{ value: 'activity', label: 'Activity', iconLeft: 'lucide-activity' },
{ value: 'settings', label: 'Settings', iconLeft: 'lucide-settings' },
]"
>
<template #tab-panel="{ tab }">
<div class="mt-3 rounded-6 border border-outline-gray-1 p-4 text-ink-gray-7">
{{ tab.label }} content
</div>
</template>
</Tabs>Underline
The default variant. Common on record pages: one trigger per section, with a leading icon.
<script setup lang="ts">
import { ref } from 'vue'
import { Tabs, TabList, TabTrigger, TabPanel } from 'frappe-ui'
const tab = ref('emails')
const tabs = [
{ value: 'activity', label: 'Activity', icon: 'lucide-activity' },
{ value: 'emails', label: 'Emails', icon: 'lucide-mail' },
{ value: 'calls', label: 'Calls', icon: 'lucide-phone' },
{ value: 'tasks', label: 'Tasks', icon: 'lucide-circle-check' },
{ value: 'notes', label: 'Notes', icon: 'lucide-file-text' },
{ value: 'files', label: 'Files', icon: 'lucide-file' },
]
</script>
<template>
<div class="w-full max-w-2xl">
<Tabs v-model="tab">
<TabList variant="underline">
<TabTrigger
v-for="t in tabs"
:key="t.value"
:value="t.value"
:label="t.label"
:icon-left="t.icon"
/>
</TabList>
<TabPanel v-for="t in tabs" :key="t.value" :value="t.value">
<div class="p-4 text-base text-ink-gray-7">
{{ t.label }} content for this record shows up here.
</div>
</TabPanel>
</Tabs>
</div>
</template>Browser tabs
The browser-tab variant renders attached tabs. Use the #suffix slot for count badges.
<script setup lang="ts">
import { ref } from 'vue'
import { Tabs, TabList, TabTrigger, Badge } from 'frappe-ui'
const tab = ref('contact')
const tabs = [
{ value: 'deals', label: 'Deals', count: '14' },
{ value: 'contact', label: 'Contact', count: '25' },
{ value: 'organizations', label: 'Organizations', count: '02' },
]
</script>
<template>
<Tabs v-model="tab">
<TabList variant="browser-tab">
<TabTrigger
v-for="t in tabs"
:key="t.value"
:value="t.value"
:label="t.label"
>
<template #suffix>
<Badge size="sm">{{ t.count }}</Badge>
</template>
</TabTrigger>
</TabList>
</Tabs>
</template>A TabPanel attaches flush below the tab bar:
<script setup lang="ts">
import { ref } from 'vue'
import { Tabs, TabList, TabTrigger, TabPanel, Badge } from 'frappe-ui'
const tab = ref('deals')
const tabs = [
{ value: 'deals', label: 'Deals', count: '14' },
{ value: 'contact', label: 'Contact', count: '25' },
{ value: 'organizations', label: 'Organizations', count: '02' },
]
const deals = [
{
no: 1,
organization: 'Attentive',
returns: '0.00',
status: 'Negotiation',
theme: 'amber',
email: '[email protected]',
},
{
no: 2,
organization: 'Gumroad',
returns: '20,000.00',
status: 'Ready to Close',
theme: 'violet',
email: '[email protected]',
},
{
no: 3,
organization: 'Evergreen',
returns: '10,000.00',
status: 'Qualification',
theme: 'blue',
email: '[email protected]',
},
] as const
</script>
<template>
<div class="w-full max-w-3xl">
<Tabs v-model="tab">
<TabList variant="browser-tab" class="px-2">
<TabTrigger
v-for="t in tabs"
:key="t.value"
:value="t.value"
:label="t.label"
>
<template #suffix>
<Badge size="sm">{{ t.count }}</Badge>
</template>
</TabTrigger>
</TabList>
<TabPanel value="deals">
<table class="w-full border-collapse text-base">
<thead>
<tr class="text-left text-ink-gray-5">
<th class="border-b border-outline-gray-1 p-2 font-normal">No</th>
<th class="border-b border-outline-gray-1 p-2 font-normal">
Organization
</th>
<th
class="border-b border-outline-gray-1 p-2 text-right font-normal"
>
Annual Returns
</th>
<th class="border-b border-outline-gray-1 p-2 font-normal">
Status
</th>
<th class="border-b border-outline-gray-1 p-2 font-normal">
Email
</th>
</tr>
</thead>
<tbody>
<tr v-for="deal in deals" :key="deal.no" class="text-ink-gray-7">
<td class="border-b border-outline-gray-1 p-2">{{ deal.no }}</td>
<td class="border-b border-outline-gray-1 p-2">
{{ deal.organization }}
</td>
<td class="border-b border-outline-gray-1 p-2 text-right">
{{ deal.returns }}
</td>
<td class="border-b border-outline-gray-1 p-2">
<Badge :theme="deal.theme">{{ deal.status }}</Badge>
</td>
<td class="border-b border-outline-gray-1 p-2">
{{ deal.email }}
</td>
</tr>
</tbody>
</table>
</TabPanel>
<TabPanel value="contact">
<div class="p-4 text-base text-ink-gray-7">Contacts show up here.</div>
</TabPanel>
<TabPanel value="organizations">
<div class="p-4 text-base text-ink-gray-7">
Organizations show up here.
</div>
</TabPanel>
</Tabs>
</div>
</template>Icon rail
Set vertical on the root for vertical orientation. An icon-only trigger takes icon without a default slot; its label becomes the accessible name.
vertical sets the orientation, not the layout. In composed mode the root ships no layout classes, so add class="flex" yourself to put the list beside the panel. Shorthand mode (tabs prop) owns the whole structure and does this for you.
<script setup lang="ts">
import { ref } from 'vue'
import { Tabs, TabList, TabTrigger, TabPanel } from 'frappe-ui'
const tab = ref('home')
const tabs = [
{ value: 'search', label: 'Search', icon: 'lucide-search' },
{ value: 'inbox', label: 'Inbox', icon: 'lucide-inbox' },
{ value: 'home', label: 'Home', icon: 'lucide-house' },
{ value: 'people', label: 'People', icon: 'lucide-users' },
{ value: 'settings', label: 'Settings', icon: 'lucide-settings' },
]
</script>
<template>
<Tabs v-model="tab" vertical class="flex gap-4">
<TabList variant="ghost">
<TabTrigger
v-for="t in tabs"
:key="t.value"
:value="t.value"
:label="t.label"
:icon="t.icon"
/>
</TabList>
<TabPanel v-for="t in tabs" :key="t.value" :value="t.value">
<!-- Fixed width so the rail doesn't move when panel text changes. -->
<div class="w-40 p-4 text-base text-ink-gray-7">{{ t.label }} panel</div>
</TabPanel>
</Tabs>
</template>Sizes
TabList supports sm (default) and md.
<script setup lang="ts">
import { ref } from 'vue'
import { Tabs, TabList, TabTrigger } from 'frappe-ui'
const smTab = ref('all')
const mdTab = ref('all')
const tabs = [
{ value: 'all', label: 'All' },
{ value: 'events', label: 'Events' },
{ value: 'tasks', label: 'Tasks' },
]
</script>
<template>
<div class="flex flex-col gap-5 p-2">
<div class="flex items-center gap-6">
<div class="w-16 text-sm text-ink-gray-6">Small</div>
<Tabs v-model="smTab">
<TabList variant="subtle" size="sm">
<TabTrigger
v-for="t in tabs"
:key="t.value"
:value="t.value"
:label="t.label"
/>
</TabList>
</Tabs>
</div>
<div class="flex items-center gap-6">
<div class="w-16 text-sm text-ink-gray-6">Medium</div>
<Tabs v-model="mdTab">
<TabList variant="subtle" size="md">
<TabTrigger
v-for="t in tabs"
:key="t.value"
:value="t.value"
:label="t.label"
/>
</TabList>
</Tabs>
</div>
</div>
</template>Disabled
A disabled trigger cannot be selected. Keyboard navigation skips it.
<script setup lang="ts">
import { ref } from 'vue'
import { Tabs, TabList, TabTrigger, TabPanel } from 'frappe-ui'
const tab = ref('overview')
</script>
<template>
<div class="w-full max-w-md">
<Tabs v-model="tab">
<TabList variant="underline">
<TabTrigger value="overview" label="Overview" />
<TabTrigger value="billing" label="Billing" disabled />
<TabTrigger value="settings" label="Settings" />
</TabList>
<TabPanel value="overview">
<div class="p-4 text-base text-ink-gray-7">Overview panel</div>
</TabPanel>
<TabPanel value="billing">
<div class="p-4 text-base text-ink-gray-7">Billing panel</div>
</TabPanel>
<TabPanel value="settings">
<div class="p-4 text-base text-ink-gray-7">Settings panel</div>
</TabPanel>
</Tabs>
</div>
</template>Vertical browser tabs
With variant="browser-tab" and a vertical root, side sets the edge the tabs attach to.
<script setup lang="ts">
import { ref } from 'vue'
import { Tabs, TabList, TabTrigger, TabPanel } from 'frappe-ui'
const left = ref('inbox')
const right = ref('inbox')
const tabs = [
{ value: 'inbox', label: 'Inbox' },
{ value: 'drafts', label: 'Drafts' },
{ value: 'archive', label: 'Archive' },
]
</script>
<template>
<div class="flex items-start gap-10 p-2">
<div class="flex flex-col gap-3">
<div class="text-sm text-ink-gray-6">Attached left</div>
<Tabs v-model="left" vertical class="flex">
<TabList variant="browser-tab" side="left">
<TabTrigger
v-for="t in tabs"
:key="t.value"
:value="t.value"
:label="t.label"
/>
</TabList>
<TabPanel v-for="t in tabs" :key="t.value" :value="t.value">
<div class="w-32 p-4 text-base text-ink-gray-7">{{ t.label }} panel</div>
</TabPanel>
</Tabs>
</div>
<div class="flex flex-col gap-3">
<div class="text-sm text-ink-gray-6">Attached right</div>
<Tabs v-model="right" vertical class="flex">
<TabPanel v-for="t in tabs" :key="t.value" :value="t.value">
<div class="w-32 p-4 text-base text-ink-gray-7">{{ t.label }} panel</div>
</TabPanel>
<TabList variant="browser-tab" side="right">
<TabTrigger
v-for="t in tabs"
:key="t.value"
:value="t.value"
:label="t.label"
/>
</TabList>
</Tabs>
</div>
</div>
</template>Prefix slot
#prefix renders leading content inside a trigger, after iconLeft. It receives { selected, disabled }.
<script setup lang="ts">
import { ref } from 'vue'
import { Tabs, TabList, TabTrigger, TabPanel } from 'frappe-ui'
const tab = ref('open')
const tabs = [
{ value: 'open', label: 'Open', dot: 'bg-surface-green-5' },
{ value: 'pending', label: 'Pending', dot: 'bg-surface-amber-5' },
{ value: 'closed', label: 'Closed', dot: 'bg-surface-gray-5' },
]
</script>
<template>
<div class="w-full max-w-md">
<Tabs v-model="tab">
<TabList variant="underline">
<TabTrigger
v-for="t in tabs"
:key="t.value"
:value="t.value"
:label="t.label"
>
<template #prefix>
<span class="size-2 rounded-full" :class="t.dot" />
</template>
</TabTrigger>
</TabList>
<TabPanel v-for="t in tabs" :key="t.value" :value="t.value">
<div class="p-4 text-base text-ink-gray-7">
{{ t.label }} tickets show up here.
</div>
</TabPanel>
</Tabs>
</div>
</template>Route mode
A trigger with route renders as a RouterLink. With no v-model, selection derives from the current route. Clicking a trigger navigates and does not emit update:modelValue. Omit the panels and place a <router-view> outside the tabs.
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Tabs, TabList, TabTrigger } from 'frappe-ui'
// Without a `v-model`, selection derives from the current route. Clicking a
// trigger navigates; a `<router-view>` outside the tabs renders the page.
const route = useRoute()
const router = useRouter()
onMounted(() => router.replace('/mail/inbox'))
</script>
<template>
<div class="w-full max-w-md">
<Tabs>
<TabList variant="underline">
<TabTrigger value="inbox" label="Inbox" route="/mail/inbox" />
<TabTrigger value="sent" label="Sent" route="/mail/sent" />
<TabTrigger value="archive" label="Archive" route="/mail/archive" />
</TabList>
</Tabs>
<div class="p-4 text-base text-ink-gray-7">
Current route: <code>{{ route.path }}</code>
</div>
</div>
</template>Shorthand mode
For generated tab sets, pass a tabs array and the component renders the parts itself. An item with a condition renders only while the function returns true. When the selected tab disappears, the component selects the first visible trigger and emits update:modelValue.
The #tab-prefix, #tab-label, #tab-suffix, and #tab-panel slots shape every generated tab; each one receives its item as tab. Put app-defined extras in the item's data field and read them as tab.data.
<script setup lang="ts">
import { ref } from 'vue'
import { Tabs, Switch } from 'frappe-ui'
const tab = ref('discussions')
const showTasks = ref(true)
const tabs = [
{
value: 'discussions',
label: 'Discussions',
data: { content: 'Discussions for this project show up here.' },
},
{
value: 'pages',
label: 'Pages',
data: { content: 'Pages for this project show up here.' },
},
{
value: 'tasks',
label: 'Tasks',
data: { content: 'Tasks for this project show up here.' },
condition: () => showTasks.value,
},
]
</script>
<template>
<div class="flex w-full max-w-md flex-col gap-4">
<Tabs
v-model="tab"
:tabs="tabs"
variant="subtle"
class="rounded-4 border p-2"
>
<template #tab-panel="{ tab: t }">
<div class="p-4 text-base text-ink-gray-7">{{ t.data?.content }}</div>
</template>
</Tabs>
<Switch v-model="showTasks" label="Show the Tasks tab" />
</div>
</template>API Reference
Tabs
Show types
import type { Component } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
export type TabValue = string | number
export type TabsVariant = 'underline' | 'subtle' | 'ghost' | 'browser-tab'
export type TabsSize = 'sm' | 'md'
export type TabsSide = 'left' | 'right'
/** A `lucide-*` class string or a Vue component. */
export type TabIcon = string | Component
export interface TabsProps {
/** Currently selected tab value. */
modelValue?: TabValue
/** Renders tabs vertically instead of horizontally. */
vertical?: boolean
/** Forces layout direction; defaults to the resolved document direction. */
dir?: 'ltr' | 'rtl'
/** Shorthand mode only: items to generate triggers (and panels) from. */
tabs?: TabItem[]
/** Shorthand mode only; forwarded to the generated TabList. */
variant?: TabsVariant
/** Shorthand mode only; forwarded to the generated TabList. */
size?: TabsSize
/** Shorthand mode only; forwarded to the generated TabList. */
side?: TabsSide
}
export interface TabsEmits {
/** Fired when the selected tab changes. */
'update:modelValue': [value: TabValue]
}
export interface TabListProps {
/** Visual variant. */
variant?: TabsVariant
/** Size of the triggers. */
size?: TabsSize
/** browser-tab + vertical only: which edge the tabs attach to. */
side?: TabsSide
}
export interface TabTriggerProps {
value: TabValue
/** Text shown in the label region. */
label?: string
/** Icon-only trigger; `label` becomes the accessible name. */
icon?: TabIcon
/** Leading accent icon, rendered next to the visible label. */
iconLeft?: TabIcon
disabled?: boolean
/** Renders the trigger as a RouterLink. See route mode in the spec. */
route?: RouteLocationRaw
}
export type TabTriggerSlotProps = { selected: boolean; disabled: boolean }
export interface TabPanelProps {
value: TabValue
}
/** Item for the `tabs` shorthand on the `Tabs` root. */
export interface TabItem {
value: TabValue
label?: string
icon?: TabIcon
iconLeft?: TabIcon
disabled?: boolean
route?: RouteLocationRaw
/** Item renders only while this returns true. */
condition?: () => boolean
/**
* App-defined extras, passed to the shorthand slots as `tab.data`. Fields
* live here rather than on the item itself so a misspelled `label` or
* `route` is still a type error.
*/
data?: Record<string, unknown>
}Currently selected tab value.
Renders tabs vertically instead of horizontally.
Forces layout direction; defaults to the resolved document direction.
Shorthand mode only: items to generate triggers (and panels) from.
Shorthand mode only; forwarded to the generated TabList.
Shorthand mode only; forwarded to the generated TabList.
Shorthand mode only; forwarded to the generated TabList.
| Slot | Payload |
|---|---|
default | — Composed mode: `TabList` / `TabPanel` children. |
tab-prefix | { tab: TabItem; } & TabTriggerSlotProps Shorthand mode: leading content in every generated trigger. |
tab-label | { tab: TabItem; } & TabTriggerSlotProps Shorthand mode: replaces the label region of every generated trigger. |
tab-suffix | { tab: TabItem; } & TabTriggerSlotProps Shorthand mode: trailing content in every generated trigger. |
tab-panel | { tab: TabItem; } Shorthand mode: panel body for the selected tab. |
Composed mode: `TabList` / `TabPanel` children.
Shorthand mode: leading content in every generated trigger.
Shorthand mode: replaces the label region of every generated trigger.
Shorthand mode: trailing content in every generated trigger.
Shorthand mode: panel body for the selected tab.
| Event | Payload |
|---|---|
update:modelValue | [value: TabValue] Fired when the selected tab changes. |
Fired when the selected tab changes.
TabList
Show types
import type { Component } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
export type TabValue = string | number
export type TabsVariant = 'underline' | 'subtle' | 'ghost' | 'browser-tab'
export type TabsSize = 'sm' | 'md'
export type TabsSide = 'left' | 'right'
/** A `lucide-*` class string or a Vue component. */
export type TabIcon = string | Component
export interface TabsProps {
/** Currently selected tab value. */
modelValue?: TabValue
/** Renders tabs vertically instead of horizontally. */
vertical?: boolean
/** Forces layout direction; defaults to the resolved document direction. */
dir?: 'ltr' | 'rtl'
/** Shorthand mode only: items to generate triggers (and panels) from. */
tabs?: TabItem[]
/** Shorthand mode only; forwarded to the generated TabList. */
variant?: TabsVariant
/** Shorthand mode only; forwarded to the generated TabList. */
size?: TabsSize
/** Shorthand mode only; forwarded to the generated TabList. */
side?: TabsSide
}
export interface TabsEmits {
/** Fired when the selected tab changes. */
'update:modelValue': [value: TabValue]
}
export interface TabListProps {
/** Visual variant. */
variant?: TabsVariant
/** Size of the triggers. */
size?: TabsSize
/** browser-tab + vertical only: which edge the tabs attach to. */
side?: TabsSide
}
export interface TabTriggerProps {
value: TabValue
/** Text shown in the label region. */
label?: string
/** Icon-only trigger; `label` becomes the accessible name. */
icon?: TabIcon
/** Leading accent icon, rendered next to the visible label. */
iconLeft?: TabIcon
disabled?: boolean
/** Renders the trigger as a RouterLink. See route mode in the spec. */
route?: RouteLocationRaw
}
export type TabTriggerSlotProps = { selected: boolean; disabled: boolean }
export interface TabPanelProps {
value: TabValue
}
/** Item for the `tabs` shorthand on the `Tabs` root. */
export interface TabItem {
value: TabValue
label?: string
icon?: TabIcon
iconLeft?: TabIcon
disabled?: boolean
route?: RouteLocationRaw
/** Item renders only while this returns true. */
condition?: () => boolean
/**
* App-defined extras, passed to the shorthand slots as `tab.data`. Fields
* live here rather than on the item itself so a misspelled `label` or
* `route` is still a type error.
*/
data?: Record<string, unknown>
}Visual variant.
Size of the triggers.
browser-tab + vertical only: which edge the tabs attach to.
| Slot | Payload |
|---|---|
default | — |
TabTrigger
Show types
import type { Component } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
export type TabValue = string | number
export type TabsVariant = 'underline' | 'subtle' | 'ghost' | 'browser-tab'
export type TabsSize = 'sm' | 'md'
export type TabsSide = 'left' | 'right'
/** A `lucide-*` class string or a Vue component. */
export type TabIcon = string | Component
export interface TabsProps {
/** Currently selected tab value. */
modelValue?: TabValue
/** Renders tabs vertically instead of horizontally. */
vertical?: boolean
/** Forces layout direction; defaults to the resolved document direction. */
dir?: 'ltr' | 'rtl'
/** Shorthand mode only: items to generate triggers (and panels) from. */
tabs?: TabItem[]
/** Shorthand mode only; forwarded to the generated TabList. */
variant?: TabsVariant
/** Shorthand mode only; forwarded to the generated TabList. */
size?: TabsSize
/** Shorthand mode only; forwarded to the generated TabList. */
side?: TabsSide
}
export interface TabsEmits {
/** Fired when the selected tab changes. */
'update:modelValue': [value: TabValue]
}
export interface TabListProps {
/** Visual variant. */
variant?: TabsVariant
/** Size of the triggers. */
size?: TabsSize
/** browser-tab + vertical only: which edge the tabs attach to. */
side?: TabsSide
}
export interface TabTriggerProps {
value: TabValue
/** Text shown in the label region. */
label?: string
/** Icon-only trigger; `label` becomes the accessible name. */
icon?: TabIcon
/** Leading accent icon, rendered next to the visible label. */
iconLeft?: TabIcon
disabled?: boolean
/** Renders the trigger as a RouterLink. See route mode in the spec. */
route?: RouteLocationRaw
}
export type TabTriggerSlotProps = { selected: boolean; disabled: boolean }
export interface TabPanelProps {
value: TabValue
}
/** Item for the `tabs` shorthand on the `Tabs` root. */
export interface TabItem {
value: TabValue
label?: string
icon?: TabIcon
iconLeft?: TabIcon
disabled?: boolean
route?: RouteLocationRaw
/** Item renders only while this returns true. */
condition?: () => boolean
/**
* App-defined extras, passed to the shorthand slots as `tab.data`. Fields
* live here rather than on the item itself so a misspelled `label` or
* `route` is still a type error.
*/
data?: Record<string, unknown>
}Text shown in the label region.
Icon-only trigger; `label` becomes the accessible name.
Leading accent icon, rendered next to the visible label.
Renders the trigger as a RouterLink. See route mode in the spec.
| Slot | Payload |
|---|---|
prefix | TabTriggerSlotProps Leading content, after `iconLeft`. |
default | TabTriggerSlotProps Replaces the label region. |
suffix | TabTriggerSlotProps Trailing content (badges, counts). |
Leading content, after `iconLeft`.
Replaces the label region.
Trailing content (badges, counts).
TabPanel
Show types
import type { Component } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
export type TabValue = string | number
export type TabsVariant = 'underline' | 'subtle' | 'ghost' | 'browser-tab'
export type TabsSize = 'sm' | 'md'
export type TabsSide = 'left' | 'right'
/** A `lucide-*` class string or a Vue component. */
export type TabIcon = string | Component
export interface TabsProps {
/** Currently selected tab value. */
modelValue?: TabValue
/** Renders tabs vertically instead of horizontally. */
vertical?: boolean
/** Forces layout direction; defaults to the resolved document direction. */
dir?: 'ltr' | 'rtl'
/** Shorthand mode only: items to generate triggers (and panels) from. */
tabs?: TabItem[]
/** Shorthand mode only; forwarded to the generated TabList. */
variant?: TabsVariant
/** Shorthand mode only; forwarded to the generated TabList. */
size?: TabsSize
/** Shorthand mode only; forwarded to the generated TabList. */
side?: TabsSide
}
export interface TabsEmits {
/** Fired when the selected tab changes. */
'update:modelValue': [value: TabValue]
}
export interface TabListProps {
/** Visual variant. */
variant?: TabsVariant
/** Size of the triggers. */
size?: TabsSize
/** browser-tab + vertical only: which edge the tabs attach to. */
side?: TabsSide
}
export interface TabTriggerProps {
value: TabValue
/** Text shown in the label region. */
label?: string
/** Icon-only trigger; `label` becomes the accessible name. */
icon?: TabIcon
/** Leading accent icon, rendered next to the visible label. */
iconLeft?: TabIcon
disabled?: boolean
/** Renders the trigger as a RouterLink. See route mode in the spec. */
route?: RouteLocationRaw
}
export type TabTriggerSlotProps = { selected: boolean; disabled: boolean }
export interface TabPanelProps {
value: TabValue
}
/** Item for the `tabs` shorthand on the `Tabs` root. */
export interface TabItem {
value: TabValue
label?: string
icon?: TabIcon
iconLeft?: TabIcon
disabled?: boolean
route?: RouteLocationRaw
/** Item renders only while this returns true. */
condition?: () => boolean
/**
* App-defined extras, passed to the shorthand slots as `tab.data`. Fields
* live here rather than on the item itself so a misspelled `label` or
* `route` is still a type error.
*/
data?: Record<string, unknown>
}| Slot | Payload |
|---|---|
default | — |
Migration from v0
The v0 Tabs was one component driven by a tabs array and an index-based v-model. The v1 API is composed: the model is the trigger value, and the app owns the TabList and TabPanel elements. See the migration guide for the full list.
| Before | After |
|---|---|
v-model="index" (number) | v-model="value" (trigger value) |
:tabs="[{ label: 'Emails' }]" | value is required; label is display-only |
<template #tab-item> | TabTrigger props and slots |
<template #tab-panel="{ tab }"> | <TabPanel :value> children; the shorthand slot keeps the same name |
as="div" | removed — compose the container directly |
[&_[role='tablist']]:... classes | style <TabList class="..."> directly |
<!-- Before -->
<Tabs v-model="tabIndex" :tabs="[{ label: 'Emails' }, { label: 'Calls' }]">
<template #tab-panel="{ tab }">
<div>{{ tab.label }}</div>
</template>
</Tabs>
<!-- After -->
<Tabs v-model="tab">
<TabList>
<TabTrigger value="emails" label="Emails" />
<TabTrigger value="calls" label="Calls" />
</TabList>
<TabPanel value="emails">…</TabPanel>
<TabPanel value="calls">…</TabPanel>
</Tabs>