Components

Tabs

Switch between views with the keyboard-accessible UiTabs component.

UiTabs renders a keyboard-accessible tab list (arrow keys, Home/End). Pass an array of tab items and bind the selection with v-model:active-tab.

Add tabs

<script setup lang="ts">
import type { ITabItem } from '@colorffy/ui'
import { ref } from 'vue'

const tabs: ITabItem[] = [
  { id: 'overview', label: 'Overview' },
  { id: 'pricing', label: 'Pricing' },
  { id: 'faq', label: 'FAQ', disabled: true },
]
const active = ref('overview')
</script>

<template>
  <UiTabs :tabs="tabs" v-model:active-tab="active" />

  <section v-show="active === 'overview'">Overview panel</section>
  <section v-show="active === 'pricing'">Pricing panel</section>
</template>

Add a badge to a tab

Each tab item accepts an optional badge (a Partial<IBadgeProps>) rendered after the label — handy for counts or status hints.

<script setup lang="ts">
const tabs: ITabItem[] = [
  { id: 'inbox', label: 'Inbox', badge: { text: '12', variant: 'primary', pill: true } },
  { id: 'archived', label: 'Archived', badge: { text: 'New', variant: 'success' } },
  { id: 'spam', label: 'Spam' },
]
</script>

Style as pills or contrast

<template>
  <UiTabs :tabs="tabs" pill-tabs />
  <UiTabs :tabs="tabs" contrast-tabs />
</template>

Add a leading icon to a tab

Each tab item accepts an optional icon (a Material Symbols entity code, same convention as INavItem.icon) rendered before the label.

<script setup lang="ts">
const tabs: ITabItem[] = [
  { id: 'overview', label: 'Overview', icon: '&#xe88a;' },
  { id: 'details', label: 'Details', icon: '&#xe873;' },
]
</script>

Icon-only tabs

Set iconOnly to show only the icon of tabs that have one, as square buttons. The label stays the tab's accessible name (visually hidden) and shows as a native tooltip on hover. Tabs without an icon keep their label, and badges still render.

<template>
  <UiTabs :tabs="tabs" pill-tabs rounded icon-only />
</template>

Fluid (equal-width) tabs

Set fluid to stretch every tab to fill the available width equally.

<template>
  <UiTabs :tabs="tabs" fluid />
</template>

Small tabs

Set size="sm" to render the tab buttons at the default button height and font size instead of the larger tab defaults.

<template>
  <UiTabs :tabs="tabs" pill-tabs size="sm" />
</template>

Fit and rounded pill tabs

By default the tab bar spans its container. Set fit to make it only as wide as its tabs, and rounded to make pill tabs and their indicator fully rounded. rounded only applies together with pillTabs, and fit is ignored when fluid is set.

<template>
  <UiTabs :tabs="tabs" pill-tabs fit rounded />
</template>

Overflow scrolling

When the tab list is wider than its container, it scrolls horizontally with a thin scrollbar instead of wrapping or clipping. No extra prop is needed — this is the default behavior of UiTabs.

Props

PropTypeDefaultDescription
tabsITabItem[]—Tab items: { id, label, disabled?, panelId?, badge?, icon? }.
activeTabstringfirst tabId of the active tab.
pillTabsbooleanfalsePill styling.
contrastTabsbooleanfalseContrast styling.
fluidbooleanfalseEqual-width tabs that stretch to fill the available space.
fitbooleanfalseThe tab bar is only as wide as its tabs. Ignored when fluid is set.
roundedbooleanfalseFully rounded pill tabs and indicator. Requires pillTabs.
iconOnlybooleanfalseTabs with an icon show only the icon; the label becomes the accessible name and a hover tooltip.
size'sm' | 'md' | nullnull'sm' uses the default button height and font size. 'md'/null keep the larger tab size.

Emits: update:activeTab(tabId: string), used by v-model:active-tab.

The active indicator (the underline, or the raised pill with pillTabs) is positioned and animated entirely in CSS with anchor positioning — no JavaScript is involved, and the slide is skipped under prefers-reduced-motion: reduce. Where anchor positioning is unsupported, the active tab falls back to its own bottom border — or to a filled, shadowed background for pillTabs — so nothing is lost. The slide covers up to 16 tabs; past that the indicator still lands correctly, it just jumps.
For a compact segmented switch, use UiSegmentedControls; it takes the same tab items.

CSS variables

Set these on :root, or on any wrapper to restyle only the tabs inside it. The --cffy-tabs-pills-* variables only affect pillTabs; contrastTabs and rounded keep their own background and radius. Hover and pressed backgrounds come from the shared state layer.

:root {
  --cffy-tabs-indicator-color: var(--cffy-accent-a10);
  --cffy-tabs-link-active-color: var(--cffy-primary-a10);
  --cffy-tabs-pills-bg-color: var(--cffy-surface-container);
}
VariableDefaultControls
--cffy-tabs-gap--cffy-space-8Space between tabs
--cffy-tabs-border-color--cffy-outline-buttonBottom border of underline tabs
--cffy-tabs-indicator-color / --cffy-tabs-indicator-size--cffy-primary-a10 / 3pxUnderline indicator
--cffy-tabs-easing / --cffy-tabs-duration--cffy-ease-decelerate / --cffy-duration-300Indicator slide
--cffy-tabs-link-color / --cffy-tabs-link-font-weight--cffy-muted-a10 / --cffy-fw-500Tab text
--cffy-tabs-link-hover-color--cffy-on-backgroundHovered tab text
--cffy-tabs-link-active-color--cffy-on-backgroundActive tab text
--cffy-tabs-pills-bg-color--cffy-surface-container-lowPill tabs container
--cffy-tabs-pills-radius1.5 × --cffy-shape-control (12px)Pill and indicator corners (the container follows)
--cffy-tabs-pills-indicator-bg-color / --cffy-tabs-pills-indicator-shadow--cffy-surface-body-pane / --cffy-shadow-mdRaised pill behind the active tab
Copyright © 2026