Tabs
Switches between panels of content, or between pages. To pick a value for a filter, a setting or a form field, use TabButtons instead.
<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="pt-3 text-ink-gray-7">
{{ tab.label }} content
</div>
</template>
</Tabs>Anatomy
Tabs holds the selected value. TabList draws the row of triggers, and each TabPanel shows while its value is selected. You lay the parts out and style them directly.
<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>Examples
Record sections
The default underline variant with a leading icon on each trigger, as on a CRM record page.
<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="py-4 text-base text-ink-gray-7">
{{ t.label }} content for this record shows up here.
</div>
</TabPanel>
</Tabs>
</div>
</template>Tabs above a table
The browser-tab variant attaches the tabs to the panel below. The #suffix slot holds a count on each tab.
<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">
<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>
<!-- A fixed height keeps the page from jumping when the tab changes. -->
<TabPanel value="deals" class="h-40">
<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" class="h-40">
<div class="px-2 py-4 text-base text-ink-gray-7">
Contacts show up here.
</div>
</TabPanel>
<TabPanel value="organizations" class="h-40">
<div class="px-2 py-4 text-base text-ink-gray-7">
Organizations show up here.
</div>
</TabPanel>
</Tabs>
</div>
</template>Ticket status
The #prefix slot puts a status dot before each label. It receives { active, 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="py-4 text-base text-ink-gray-7">
{{ t.label }} tickets show up here.
</div>
</TabPanel>
</Tabs>
</div>
</template>Icon rail
A vertical ghost list of icon-only triggers. A trigger with icon and no default slot shows only the icon, and its label becomes the name screen readers announce.
<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>Mailbox pages
A trigger with route renders as a link. Without a v-model, the selected tab follows the current route. Leave out the panels and put 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="py-4 text-base text-ink-gray-7">
Current route: <code>{{ route.path }}</code>
</div>
</div>
</template>Tabs from data
Pass a tabs array and the component renders every part for you. An item with a condition shows only while the function returns true. The #tab-prefix, #tab-label, #tab-suffix and #tab-panel slots shape each tab, and receive the item as tab. Put your own fields in the item's data 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="px-2 py-4 text-base text-ink-gray-7">
{{ t.data?.content }}
</div>
</template>
</Tabs>
<Switch v-model="showTasks" label="Show the Tasks tab" />
</div>
</template>Behavior
Vertical tabs
vertical on Tabs changes the orientation and the arrow keys, not the layout. Add class="flex" to put the list beside the panel. With variant="browser-tab", edge="start" or edge="end" on TabList sets the edge of the list the tabs attach to. start is the left edge in left-to-right text. The tabs array mode lays itself out.
Disabled tabs
A disabled trigger cannot be selected, and the arrow keys skip it.
When the selected tab goes away
In tabs array mode, if the selected tab's condition turns false, the first visible tab is selected and update:modelValue fires.
API Reference
Tabs
Show types
import type { Component } from 'vue'
import type { RouteDestination } from '../shared/route'
export type TabValue = string | number
export type TabsVariant = 'underline' | 'subtle' | 'ghost' | 'browser-tab'
export type TabsSize = 'sm' | 'md'
export type TabsEdge = 'start' | 'end'
/** 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. */
edge?: TabsEdge
}
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: the edge of the list the tabs attach to.
* `start` is the left edge in left-to-right text.
*/
edge?: TabsEdge
}
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?: RouteDestination
}
export type TabTriggerSlotProps = { active: 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?: RouteDestination
/** 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 { RouteDestination } from '../shared/route'
export type TabValue = string | number
export type TabsVariant = 'underline' | 'subtle' | 'ghost' | 'browser-tab'
export type TabsSize = 'sm' | 'md'
export type TabsEdge = 'start' | 'end'
/** 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. */
edge?: TabsEdge
}
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: the edge of the list the tabs attach to.
* `start` is the left edge in left-to-right text.
*/
edge?: TabsEdge
}
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?: RouteDestination
}
export type TabTriggerSlotProps = { active: 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?: RouteDestination
/** 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: the edge of the list the tabs attach to. `start` is the left edge in left-to-right text.
| Slot | Payload |
|---|---|
default | — |
TabTrigger
Show types
import type { Component } from 'vue'
import type { RouteDestination } from '../shared/route'
export type TabValue = string | number
export type TabsVariant = 'underline' | 'subtle' | 'ghost' | 'browser-tab'
export type TabsSize = 'sm' | 'md'
export type TabsEdge = 'start' | 'end'
/** 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. */
edge?: TabsEdge
}
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: the edge of the list the tabs attach to.
* `start` is the left edge in left-to-right text.
*/
edge?: TabsEdge
}
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?: RouteDestination
}
export type TabTriggerSlotProps = { active: 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?: RouteDestination
/** 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 { RouteDestination } from '../shared/route'
export type TabValue = string | number
export type TabsVariant = 'underline' | 'subtle' | 'ghost' | 'browser-tab'
export type TabsSize = 'sm' | 'md'
export type TabsEdge = 'start' | 'end'
/** 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. */
edge?: TabsEdge
}
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: the edge of the list the tabs attach to.
* `start` is the left edge in left-to-right text.
*/
edge?: TabsEdge
}
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?: RouteDestination
}
export type TabTriggerSlotProps = { active: 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?: RouteDestination
/** 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 | — |