TabButtons

A radio-group based tab control for switching between compact views.

Status: work in progress. The v1 API and implementation are still being polished, especially route/link behavior, option compatibility, and browser-tab details. Treat this component as preview-quality until this note is removed.

Playground

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

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"
        type="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"
        type="browser-tab"
        vertical
        direction="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"
        type="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.modelValue]" 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.modelValue] }}
        </span>
      </template>
    </TabButtons>

    <TabButtons
      v-model="inboxTab"
      :options="inboxButtons"
      type="underline"
      size="md"
    >
      <template #prefix="{ button }">
        <span :class="inboxIcons[button.modelValue]" 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.modelValue] }}
        </span>
      </template>
    </TabButtons>

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

API Reference

Show types
typescript
import type { Component } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import type { PillSize } from '../Pill'

export type TabButtonValue = string | number | boolean
export type TabButtonIcon = string | Component
export type NativeButtonClass = string | string[] | Record<string, boolean>
export type TabButtonsType = 'subtle' | 'ghost' | 'underline' | 'browser-tab'
export type TabButtonsDirection = 'left' | 'right'

export interface TabButton {
  label?: string | number
  value?: TabButtonValue
  /** Icon-only tab; `label` becomes accessibility text. */
  icon?: TabButtonIcon
  /** Leading accent icon, rendered next to the visible label. */
  iconLeft?: TabButtonIcon
  /** Trailing accent icon, rendered next to the visible label. */
  iconRight?: TabButtonIcon
  active?: boolean
  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[]

  /** @deprecated Use `options` instead. */
  buttons?: TabButton[]

  modelValue?: TabButtonValue
  type?: TabButtonsType
  size?: PillSize
  vertical?: boolean

  /** Edge the active browser tab attaches to. Only used when `type='browser-tab'` and `vertical`. */
  direction?: TabButtonsDirection
}

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

List of options to render.

buttons

Deprecated — Use `options` instead.

modelValue
TabButtonValue
type
= "subtle"
TabButtonsType
size
= "sm"
PillSize
vertical
= false
boolean
direction
= "left"
TabButtonsDirection

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

prefix
{ button: { key: string; label: string | number | undefined; icon: TabButtonIcon | undefined; iconLe
suffix
{ button: { key: string; label: string | number | undefined; icon: TabButtonIcon | undefined; iconLe
update:modelValue
[value: TabButtonValue | undefined]

Fired when the model value changes.

Migration from v0

TabButtons no longer wraps <Button> internally — each tab is now a native <button>, <a href>, or <RouterLink> rendering a <Pill> for its visual treatment. This is a breaking change for consumers that were passing 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 / iconRight when you want an accent icon next to a visible label.
  • 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 now 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.