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
<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
<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
<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.
<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.
<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.
<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.
<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.
<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.
<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
<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
<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
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]
}List of options to render.
Visual variant, shared with the Tabs family.
Edge the active browser tab attaches to. Only used when `variant='browser-tab'` and `vertical`.
Buttons stretch to fill the container width.
| Slot | Payload |
|---|---|
prefix | { button: { value: TabValue; customClass: NativeButtonClass | undefined; visibleLabel: boolean; acce |
suffix | { button: { value: TabValue; customClass: NativeButtonClass | undefined; visibleLabel: boolean; acce |
| Event | Payload |
|---|---|
update:modelValue | [value: TabValue] Fired when the model value changes. |
Fired when the model value changes.
Migration from v0
See the migration guide for the full list.
typeis renamed tovariant, matchingTabList.- The deprecated
buttonsprop is removed — useoptions. valueis required on every option, and boolean values are no longer accepted. The label-as-value fallback and theactive: truefallback are removed; the model is the single source of truth.fluidis 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,prefixon individual options are no longer honored. UseButtonorPilldirectly if you need per-tab theming or a loading spinner.hideLabelon options is gone. Useiconfor an icon-only tab — itslabel, if provided, is automatically exposed as accessibility text. UseiconLeftfor an accent icon before a visible label, and the#suffixslot for trailing content.routeandhrefon options are honored: a tab renders as a<RouterLink>whenrouteis set, or an<a href target=_blank>whenhrefis set.- The per-tab
tooltipvalue surfaces as the nativetitleattribute rather than the floating<Tooltip>popover. Wrap theTabButtonsinstance in a custom tooltip if you need styled behavior.