Frappe UIFrappe UI

TabButtons

A segmented control with radiogroup semantics. It picks a value for a filter, a setting, or a form field.

Use Tabs when the UI switches visible panels or routes. The two share variants, sizes, and the item vocabulary, and are pixel-identical at the same variant and size. The choice is semantic, not visual.

Playground

variant
size
vertical
prefix
suffix
<script setup>
import { ref } from 'vue'
import { TabButtons } from 'frappe-ui'

const tab = ref('activity')
const options = [
  { label: 'Overview', value: 'overview' },
  { label: 'Activity', value: 'activity' },
  { label: 'Settings', value: 'settings' },
]
</script>

<template>
  <TabButtons
    v-model="tab"
    :options="options"
  />
</template>

Variants

Subtle
Ghost
Underline
Browser tab
vue
<script setup>
import { ref } from 'vue'
import { TabButtons } from 'frappe-ui'

const buttons = [
  { label: 'Home', value: 'home', iconLeft: 'lucide-home' },
  { label: 'Task', value: 'task', iconLeft: 'lucide-list-checks' },
  { label: 'Contact', value: 'contact', iconLeft: 'lucide-contact' },
  { label: 'Others', value: 'others', iconLeft: 'lucide-globe' },
]

const subtle = ref('task')
const ghost = ref('task')
const underline = ref('task')
const browserTab = ref('task')
</script>

<template>
  <div class="flex flex-col gap-6 p-2">
    <div class="flex items-center gap-6">
      <div class="w-24 text-sm text-ink-gray-6">Subtle</div>
      <TabButtons v-model="subtle" :options="buttons" variant="subtle" />
    </div>

    <div class="flex items-center gap-6">
      <div class="w-24 text-sm text-ink-gray-6">Ghost</div>
      <TabButtons v-model="ghost" :options="buttons" variant="ghost" />
    </div>

    <div class="flex items-center gap-6">
      <div class="w-24 text-sm text-ink-gray-6">Underline</div>
      <TabButtons v-model="underline" :options="buttons" variant="underline" />
    </div>

    <div class="flex items-center gap-6">
      <div class="w-24 text-sm text-ink-gray-6">Browser tab</div>
      <TabButtons v-model="browserTab" :options="buttons" variant="browser-tab" />
    </div>
  </div>
</template>

Sizes

Small
Medium
vue
<script setup>
import { ref } from 'vue'
import { TabButtons } from 'frappe-ui'

const current = ref('list')
const currentMd = ref('list')

const buttons = [
  { label: 'List', value: 'list', iconLeft: 'lucide-list' },
  { label: 'Board', value: 'board', iconLeft: 'lucide-columns-3' },
  { label: 'Calendar', value: 'calendar', iconLeft: 'lucide-calendar' },
]
</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>
      <TabButtons v-model="current" :options="buttons" size="sm" />
    </div>
    <div class="flex items-center gap-6">
      <div class="w-16 text-sm text-ink-gray-6">Medium</div>
      <TabButtons v-model="currentMd" :options="buttons" size="md" />
    </div>
  </div>
</template>

Filter

The plain subtle control: a content filter above a list.

vue
<script setup>
import { ref } from 'vue'
import { TabButtons } from 'frappe-ui'

const filter = ref('discussions')

const filters = [
  { label: 'All', value: 'all' },
  { label: 'Discussions', value: 'discussions' },
  { label: 'Tasks', value: 'tasks' },
]
</script>

<template>
  <TabButtons v-model="filter" :options="filters" />
</template>

Settings rows

Value pickers on the trailing side of settings rows.

General

Time format
Choose the way times are displayed
Start day
Choose what day the calendar week should start
vue
<script setup>
import { ref } from 'vue'
import { TabButtons } from 'frappe-ui'

const timeFormat = ref('12hrs')
const startDay = ref('sunday')

const timeFormats = [
  { label: '12hrs', value: '12hrs' },
  { label: '24hrs', value: '24hrs' },
]

const startDays = [
  { label: 'Sunday', value: 'sunday' },
  { label: 'Monday', value: 'monday' },
]
</script>

<template>
  <div class="w-full max-w-lg">
    <h3 class="text-lg font-semibold text-ink-gray-9">General</h3>
    <div class="mt-2 divide-y divide-outline-gray-1">
      <div class="flex items-center justify-between gap-4 py-3">
        <div>
          <div class="text-base font-medium text-ink-gray-8">Time format</div>
          <div class="mt-1 text-base text-ink-gray-6">
            Choose the way times are displayed
          </div>
        </div>
        <TabButtons v-model="timeFormat" :options="timeFormats" />
      </div>
      <div class="flex items-center justify-between gap-4 py-3">
        <div>
          <div class="text-base font-medium text-ink-gray-8">Start day</div>
          <div class="mt-1 text-base text-ink-gray-6">
            Choose what day the calendar week should start
          </div>
        </div>
        <TabButtons v-model="startDay" :options="startDays" />
      </div>
    </div>
  </div>
</template>

Toolbar

The sm size lines up with toolbar buttons and selects.

vue
<script setup>
import { ref } from 'vue'
import { Avatar, Button, TabButtons } from 'frappe-ui'

const filter = ref('events')

const filters = [
  { label: 'All', value: 'all' },
  { label: 'Events', value: 'events' },
  { label: 'Tasks', value: 'tasks' },
]
</script>

<template>
  <div class="flex w-full flex-wrap items-center gap-2">
    <Button variant="ghost" label="Jun 2026" icon-right="lucide-chevron-down" />
    <div class="flex items-center">
      <Button variant="ghost" icon="lucide-chevron-left" label="Previous" />
      <Button variant="ghost" label="Today" />
      <Button variant="ghost" icon="lucide-chevron-right" label="Next" />
    </div>
    <Button label="Week" icon-right="lucide-chevron-down" />
    <TabButtons v-model="filter" :options="filters" />
    <Button
      label="Lead"
      icon-left="lucide-users"
      icon-right="lucide-chevron-down"
    />
    <Button label="Santhosh" icon-right="lucide-chevron-down">
      <template #prefix>
        <Avatar size="xs" label="Santhosh" />
      </template>
    </Button>
  </div>
</template>

View toggle

Icon-only options take icon; the label becomes the accessible name.

Holidays

Add holidays here to make sure they're excluded from SLA calculations

vue
<script setup>
import { ref } from 'vue'
import { TabButtons } from 'frappe-ui'

const view = ref('list')

const views = [
  { label: 'List view', value: 'list', icon: 'lucide-list' },
  { label: 'Calendar view', value: 'calendar', icon: 'lucide-calendar' },
]
</script>

<template>
  <div class="flex w-full items-center justify-between gap-4">
    <div>
      <h3 class="text-lg font-semibold text-ink-gray-9">Holidays</h3>
      <p class="mt-1 text-base text-ink-gray-6">
        Add holidays here to make sure they're excluded from SLA calculations
      </p>
    </div>
    <TabButtons v-model="view" :options="views" />
  </div>
</template>

Fluid

With fluid, the buttons stretch to equal widths and fill the container.

vue
<script setup>
import { ref } from 'vue'
import { TabButtons } from 'frappe-ui'

const period = ref('week')

const periods = [
  { label: 'Day', value: 'day' },
  { label: 'Week', value: 'week' },
  { label: 'Month', value: 'month' },
  { label: 'Year', value: 'year' },
]
</script>

<template>
  <div class="w-full max-w-md">
    <TabButtons v-model="period" :options="periods" fluid />
  </div>
</template>

Inspector rows

Fixed-width fluid controls in a property panel. Options mix icon-only and text items.

Direction
Overflow X
Align
Style
Border radius
Wrap
vue
<script setup>
import { ref } from 'vue'
import { TabButtons, TextInput } from 'frappe-ui'

const direction = ref('vertical')
const overflowX = ref('hide')
const align = ref('start')
const style = ref('alphabet')
const radius = ref('0')
const radiusMode = ref('all')
const wrap = ref('no')

const directions = [
  { label: 'Horizontal', value: 'horizontal', icon: 'lucide-move-horizontal' },
  { label: 'Vertical', value: 'vertical', icon: 'lucide-move-vertical' },
]

const overflows = [
  { label: 'Visible', value: 'visible', icon: 'lucide-eye' },
  { label: 'Hidden', value: 'hide', icon: 'lucide-eye-off' },
  { label: 'Scroll', value: 'scroll', icon: 'lucide-arrow-down-to-line' },
  { label: 'Auto', value: 'auto' },
]

const aligns = [
  { label: 'Align start', value: 'start', icon: 'lucide-align-start-vertical' },
  {
    label: 'Align center',
    value: 'center',
    icon: 'lucide-align-center-vertical',
  },
  { label: 'Align end', value: 'end', icon: 'lucide-align-end-vertical' },
]

const styles = [
  { label: 'Alphabet list', value: 'alphabet', icon: 'lucide-letter-text' },
  { label: 'Bullet list', value: 'bullet', icon: 'lucide-list' },
  { label: 'Numbered list', value: 'numbered', icon: 'lucide-list-ordered' },
]

const radiusModes = [
  { label: 'All corners', value: 'all', icon: 'lucide-square' },
  { label: 'Individual corners', value: 'individual', icon: 'lucide-scan' },
]

const wraps = [
  { label: 'Yes', value: 'yes' },
  { label: 'No', value: 'no' },
]
</script>

<template>
  <div class="flex w-72 flex-col gap-3">
    <div class="flex items-center justify-between gap-4">
      <div class="text-base text-ink-gray-7">Direction</div>
      <TabButtons
        v-model="direction"
        :options="directions"
        fluid
        class="w-36"
      />
    </div>
    <div class="flex items-center justify-between gap-4">
      <div class="text-base text-ink-gray-7">Overflow X</div>
      <TabButtons v-model="overflowX" :options="overflows" fluid class="w-36" />
    </div>
    <div class="flex items-center justify-between gap-4">
      <div class="text-base text-ink-gray-7">Align</div>
      <TabButtons v-model="align" :options="aligns" fluid class="w-36" />
    </div>
    <div class="flex items-center justify-between gap-4">
      <div class="text-base text-ink-gray-7">Style</div>
      <TabButtons v-model="style" :options="styles" fluid class="w-36" />
    </div>
    <div class="flex items-center justify-between gap-4">
      <div class="text-base text-ink-gray-7">Border radius</div>
      <div class="flex w-36 items-center gap-2">
        <TextInput v-model="radius" class="min-w-0 flex-1" />
        <TabButtons v-model="radiusMode" :options="radiusModes" />
      </div>
    </div>
    <div class="flex items-center justify-between gap-4">
      <div class="text-base text-ink-gray-7">Wrap</div>
      <TabButtons v-model="wrap" :options="wraps" fluid class="w-36" />
    </div>
  </div>
</template>

Vertical

Subtle
Underline
Browser tab
Icon sidebar
vue
<script setup>
import { ref } from 'vue'
import { TabButtons } from 'frappe-ui'

const subtle = ref('home')
const underline = ref('home')
const browserLeft = ref('home')
const sidebar = ref('home')

const navButtons = [
  { label: 'Home', value: 'home', iconLeft: 'lucide-home' },
  { label: 'Task', value: 'task', iconLeft: 'lucide-list-checks' },
  { label: 'Contact', value: 'contact', iconLeft: 'lucide-contact' },
  { label: 'Others', value: 'others', iconLeft: 'lucide-globe' },
]

const iconButtons = [
  { label: 'Search', value: 'search', icon: 'lucide-search' },
  { label: 'Inbox', value: 'inbox', icon: 'lucide-inbox' },
  { label: 'Home', value: 'home', icon: 'lucide-home' },
  { label: 'Team', value: 'team', icon: 'lucide-users' },
  { label: 'Settings', value: 'settings', icon: 'lucide-settings' },
]
</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">Subtle</div>
      <TabButtons v-model="subtle" :options="navButtons" vertical />
    </div>

    <div class="flex flex-col gap-3">
      <div class="text-sm text-ink-gray-6">Underline</div>
      <TabButtons
        v-model="underline"
        :options="navButtons"
        variant="underline"
        vertical
      />
    </div>

    <div class="flex flex-col gap-3">
      <div class="text-sm text-ink-gray-6">Browser tab</div>
      <TabButtons
        v-model="browserLeft"
        :options="navButtons"
        variant="browser-tab"
        vertical
        side="left"
      />
    </div>

    <div class="flex flex-col gap-3">
      <div class="text-sm text-ink-gray-6">Icon sidebar</div>
      <TabButtons
        v-model="sidebar"
        :options="iconButtons"
        variant="ghost"
        vertical
      />
    </div>
  </div>
</template>

Prefix and suffix

vue
<script setup>
import { ref } from 'vue'
import { TabButtons } from 'frappe-ui'

const inboxTab = ref('comments')
const viewTab = ref('list')

const inboxButtons = [
  { label: 'Inbox', value: 'inbox' },
  { label: 'Comments', value: 'comments' },
]

const inboxIcons = {
  inbox: 'lucide-inbox',
  comments: 'lucide-message-square',
}

const inboxCounts = {
  inbox: 8,
  comments: 14,
}

const viewButtons = [
  { label: 'List', value: 'list', icon: 'lucide-list' },
  { label: 'Board', value: 'board', icon: 'lucide-columns-3' },
  { label: 'Calendar', value: 'calendar', icon: 'lucide-calendar' },
]
</script>

<template>
  <div class="flex flex-col gap-6 p-2">
    <TabButtons v-model="inboxTab" :options="inboxButtons" size="md">
      <template #prefix="{ button }">
        <span :class="inboxIcons[button.value]" class="size-4 shrink-0" />
      </template>
      <template #suffix="{ button }">
        <span
          class="rounded-full bg-surface-gray-2 px-1.5 text-xs text-ink-gray-7"
        >
          {{ inboxCounts[button.value] }}
        </span>
      </template>
    </TabButtons>

    <TabButtons
      v-model="inboxTab"
      :options="inboxButtons"
      variant="underline"
      size="md"
    >
      <template #prefix="{ button }">
        <span :class="inboxIcons[button.value]" class="size-4 shrink-0" />
      </template>
      <template #suffix="{ button }">
        <span
          class="rounded-full bg-surface-gray-2 px-1.5 text-xs text-ink-gray-7"
        >
          {{ inboxCounts[button.value] }}
        </span>
      </template>
    </TabButtons>

    <TabButtons v-model="viewTab" :options="viewButtons" />
  </div>
</template>

API Reference

Show types
typescript
import type { RouteLocationRaw } from 'vue-router'
import type {
  TabIcon,
  TabsSide,
  TabsSize,
  TabsVariant,
  TabValue,
} from '../Tabs/types'

export type TabButtonValue = TabValue
export type TabButtonIcon = TabIcon
export type NativeButtonClass = string | string[] | Record<string, boolean>

export interface TabButton {
  value: TabButtonValue
  label?: string | number
  /** Icon-only tab; `label` becomes accessibility text. */
  icon?: TabButtonIcon
  /** Leading accent icon, rendered next to the visible label. */
  iconLeft?: TabButtonIcon
  disabled?: boolean
  tooltip?: string
  class?: NativeButtonClass
  /** Renders the tab as a `<RouterLink>` to the given target. */
  route?: RouteLocationRaw
  /** Renders the tab as an `<a href>`, opens in a new tab. */
  href?: string
  onClick?: (event: MouseEvent) => void
}

export interface TabButtonsProps {
  /** List of options to render. */
  options?: TabButton[]

  modelValue?: TabButtonValue

  /** Visual variant, shared with the Tabs family. */
  variant?: TabsVariant

  size?: TabsSize

  vertical?: boolean

  /** Edge the active browser tab attaches to. Only used when `variant='browser-tab'` and `vertical`. */
  side?: TabsSide

  /** Buttons stretch to fill the container width. */
  fluid?: boolean
}

export interface TabButtonsEmits {
  'update:modelValue': [value: TabButtonValue]
}
options
TabButton[]

List of options to render.

modelValue
TabValue
variant
= "subtle"
TabsVariant

Visual variant, shared with the Tabs family.

size
= "sm"
TabsSize
vertical
= false
boolean
side
= "left"
TabsSide

Edge the active browser tab attaches to. Only used when `variant='browser-tab'` and `vertical`.

fluid
= false
boolean

Buttons stretch to fill the container width.

prefix
{ button: { value: TabValue; customClass: NativeButtonClass | undefined; visibleLabel: boolean; acce
suffix
{ button: { value: TabValue; customClass: NativeButtonClass | undefined; visibleLabel: boolean; acce
update:modelValue
[value: TabValue]

Fired when the model value changes.

Migration from v0

See the migration guide for the full list.

  • type is renamed to variant, matching TabList.
  • The deprecated buttons prop is removed — use options.
  • value is required on every option, and boolean values are no longer accepted. The label-as-value fallback and the active: true fallback are removed; the model is the single source of truth.
  • fluid is new — buttons stretch to fill the container width. It replaces raw-CSS and wrapper-div workarounds.

TabButtons no longer wraps <Button> internally — each tab is a native <button>, <a href>, or <RouterLink> rendering a <Pill> for its visual treatment. This breaks consumers that passed Button props through option entries:

  • theme, variant, size, loading, prefix on individual options are no longer honored. Use Button or Pill directly if you need per-tab theming or a loading spinner.
  • hideLabel on options is gone. Use icon for an icon-only tab — its label, if provided, is automatically exposed as accessibility text. Use iconLeft for an accent icon before a visible label, and the #suffix slot for trailing content.
  • route and href on options are honored: a tab renders as a <RouterLink> when route is set, or an <a href target=_blank> when href is set.
  • The per-tab tooltip value surfaces as the native title attribute rather than the floating <Tooltip> popover. Wrap the TabButtons instance in a custom tooltip if you need styled behavior.