Frappe UIFrappe UI

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

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

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="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.

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

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

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>

Sizes

TabList supports sm (default) and md.

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

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

Attached left
Inbox panel
Attached right
Inbox panel
vue
<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 }.

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="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.

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="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.

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="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
typescript
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>
}
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.

side
TabsSide

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 { 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>
}
variant
= "underline"
TabsVariant

Visual variant.

size
= "sm"
TabsSize

Size of the triggers.

side
= "left"
TabsSide

browser-tab + vertical only: which edge the tabs attach to.

default

TabTrigger

Show types
typescript
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>
}
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
string | kt | Tt

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 { 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>
}
value*
TabValue
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.

BeforeAfter
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']]:... classesstyle <TabList class="..."> directly
vue
<!-- 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>