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.

variant
size
<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.

vue
<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.

Emails content for this record shows up here.
vue
<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.

No Organization Annual Returns Status Email
1Attentive0.00
Negotiation
[email protected]
2Gumroad20,000.00
Ready to Close
[email protected]
3Evergreen10,000.00
Qualification
[email protected]
vue
<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 }.

Open tickets show up here.
vue
<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.

Home panel
vue
<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.

Current route: /
vue
<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.

Discussions for this project show up here.
vue
<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
typescript
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>
}
modelValue
TabValue

Currently selected tab value.

vertical
= false
boolean

Renders tabs vertically instead of horizontally.

dir
"ltr" | "rtl"

Forces layout direction; defaults to the resolved document direction.

tabs
TabItem[]

Shorthand mode only: items to generate triggers (and panels) from.

variant
= "underline"
TabsVariant

Shorthand mode only; forwarded to the generated TabList.

size
= "sm"
TabsSize

Shorthand mode only; forwarded to the generated TabList.

edge
TabsEdge

Shorthand mode only; forwarded to the generated TabList.

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.

update:modelValue
[value: TabValue]

Fired when the selected tab changes.

TabList ​

Show types
typescript
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>
}
variant
= "underline"
TabsVariant

Visual variant.

size
= "sm"
TabsSize

Size of the triggers.

edge
= "start"
TabsEdge

browser-tab + vertical only: the edge of the list the tabs attach to. `start` is the left edge in left-to-right text.

default
—

TabTrigger ​

Show types
typescript
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>
}
value*
TabValue
label
string

Text shown in the label region.

icon
TabIcon

Icon-only trigger; `label` becomes the accessible name.

iconLeft
TabIcon

Leading accent icon, rendered next to the visible label.

disabled
boolean
route
RouteDestination

Renders the trigger as a RouterLink. See route mode in the spec.

prefix
TabTriggerSlotProps

Leading content, after `iconLeft`.

default
TabTriggerSlotProps

Replaces the label region.

suffix
TabTriggerSlotProps

Trailing content (badges, counts).

TabPanel ​

Show types
typescript
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>
}
value*
TabValue
default
—