Tabs
The starter kit uses SkTabs with a fluent TB builder to keep multi-section screens clean. Settings, profile, and similar screens often grow into multiple sections — tabs give a single route a structured UI without breaking the page into many routes.
Imports
import { TB } from '@lvntr/components/TabBuilder/core';
import SkTabs from '@lvntr/components/TabBuilder/SkTabs.vue';
import type { TabIconColor, TabBadgeSeverity } from '@lvntr/components/TabBuilder/core';
import type { TabChangePayload, SkTabsExposed, TabPanelMode, TabHistoryMode, TabUrlMode } from '@lvntr/components/TabBuilder/core';
Basic Example
<script setup lang="ts">
import { TB } from '@lvntr/components/TabBuilder/core';
import SkTabs from '@lvntr/components/TabBuilder/SkTabs.vue';
const tabConfig = TB.tabs()
.queryParam('tab')
.addTabs(
TB.item().key('general').label('General').icon('pi pi-user'),
TB.item().key('security').label('Security').icon('pi pi-shield'),
TB.item().key('sessions').label('Sessions').icon('pi pi-desktop'),
)
.build();
</script>
<template>
<SkTabs :config="tabConfig">
<template #general>
<p>General content</p>
</template>
<template #security>
<p>Security content</p>
</template>
<template #sessions>
<p>Session content</p>
</template>
</SkTabs>
</template>
Tabs Builder API
layout('horizontal' | 'vertical')vertical()horizontal()queryParam(string)class(string)cardTitle(string)cardSubtitle(string)isCard(boolean)addTabs(...tabs)lazy(value = true)— mount only the active panel (panels: 'active');lazy(false)clears the overridekeepAlive(value = true)— mount every panel and keep it alive across switches (panels: 'all');keepAlive(false)clears the overridehistory('push' | 'replace')— history entry written on a tab switch; defaultreplaceurlMode('server' | 'client')—serversyncs through an Inertia visit (default),clientrewrites the URL with no server requestsyncUrl(boolean)— mirror the active tab in the URL query string; defaulttrue
Tab Item API
key(string)label(string)icon(string)description(string)— secondary line under the label (vertical layout only)iconColor(color)— colored icon tile preset (vertical layout only); defaults toslate. One of:blue,amber,emerald,purple,teal,red,rose,indigo,slate,pink,orange,cyan,green,yellowbadge(value, severity?)— trailing badge (text or number). Severity:success/warn/info/danger/secondary(default)checked(value = true)— trailing green check mark; takes precedence overbadgepermission(...permissions)— hide the tab unless the user holds at least one of the given permissions (variadic; OR across multiple values — same ascanAny())role(...roles)— hide the tab unless the user holds at least one of the given roles (variadic; OR across multiple values)visible(boolean | () => boolean)disabled(boolean | () => boolean)isCard(boolean)cardTitle(string)cardSubtitle(string)
TB.item().key('billing').label('Billing').permission('billing.view', 'billing.manage'),
TB.item().key('admin-tools').label('Admin Tools').role('admin', 'superadmin'),
Component Props & Events
config: TabBuilderConfig— the built config (required)v-model(modelValue?: string) — optional two-way binding for the active tab key. In URL mode a deep link (e.g.?tab=security) wins over a different incomingmodelValueon mount; in local mode (.syncUrl(false))modelValueseeds the initial selection instead. Either way, writingmodelValuegoes through the same setter a click uses.@update:modelValue="(key: string) => …"— fires whenever the resolved active key differs frommodelValue, including immediately after mount@change="(payload: TabChangePayload) => …"— fires on every tab change after mount (the initial mount is not a change); payload is{ key, previousKey, tab }, wherepreviousKeyisnullwhen nothing was resolvable before#emptyslot — rendered alone, with no sidebar or tab strip, when no tab is selectable: every tab is gated away by.permission()/.role()/.visible(), or every visible tab is.disabled()- exposed instance (
SkTabsExposed, via a template ref) —{ activeTab: string; isActive: (key: string) => boolean }
<script setup lang="ts">
import { ref } from 'vue';
import type { TabChangePayload } from '@lvntr/components/TabBuilder/core';
const activeTab = ref('general');
function onTabChange(payload: TabChangePayload) {
console.log(payload.previousKey, '→', payload.key);
}
</script>
<template>
<SkTabs :config="tabConfig" v-model="activeTab" @change="onTabChange">
<!-- ... -->
</SkTabs>
</template>
Rich Vertical Tabs
Vertical tabs can present a richer sidebar — colored icon tile, description line, trailing badge or check mark. The sidebar itself is always wrapped in a card; .isCard(true) instead controls whether the active tab's content panel renders as a card or a transparent, flush panel — the same flag tabIsCard() reads inside SkTabs:
<script setup lang="ts">
const tabConfig = TB.tabs()
.vertical()
.isCard(true)
.addTabs(
TB.item()
.key('general')
.label('General')
.description('App name, language and logo')
.icon('pi pi-cog')
.iconColor('blue'),
TB.item()
.key('mail')
.label('Mail')
.description('SMTP and sender settings')
.icon('pi pi-envelope')
.iconColor('emerald')
.badge(3, 'warn'),
TB.item()
.key('storage')
.label('Storage')
.description('S3, Spaces and local disk')
.icon('pi pi-database')
.iconColor('purple')
.checked(),
)
.build();
</script>
description, iconColor, badge, and checked are ignored in horizontal layout.
Useful Features
- vertical or horizontal layout
- rich vertical sidebar with icon tiles, descriptions, badges, check marks
- query string sync by default, or fully local (URL-less) state via
.syncUrl(false) - role-based and permission-based visibility
- per-tab disabled logic
- optional card wrappers with title and subtitle at both tab and container level
- optional
v-modelbinding and achangeevent for host-side reactions - an
emptyslot for the nothing-selectable case (every tab gated away, or every visible tab disabled) - full keyboard/ARIA support in vertical layout
Built-in Behavior
SkTabs already includes:
- query string synchronization by default;
.syncUrl(false)keeps the active tab fully local instead - vertical sidebar mode
- optional
sidebar-headerandsidebar-footerslots in vertical layout - slot-based content keyed by the tab id
- lifecycle: defaults unchanged — vertical mounts only the active panel and unmounts it on switch, horizontal mounts every panel once and toggles visibility, so per-tab local state survives a switch only in horizontal by default.
.lazy()overrides either layout to active-only mounting (on horizontal this is PrimeVue's ownlazymode);.keepAlive()overrides either layout to mount every panel and keep it alive, hidden instead of unmounted (useful on vertical, to preserve per-tab state across switches) - URL sync:
?tab=must name a visible, enabled tab or the first selectable tab wins; a disabled tab never activates from the URL; re-selecting the active tab is a no-op;#hashis preserved across switches..urlMode('server')(default) syncs through an Inertia visit that re-resolves the page;.urlMode('client')rewrites the URL with no server request..history('replace')(default) replaces the history entry on each switch,.history('push')gives each switch its own entry..syncUrl(false)drops URL sync entirely — the active tab lives only in component state (andv-model) - accessibility (vertical layout): the tab list is
role="tablist"witharia-orientation="vertical", each tab button isrole="tab"witharia-selected/aria-controls/aria-disabledand rovingtabindex(0on the active tab,-1on the rest), and the panel is wrapped inrole="tabpanel". Arrow Down/Up move focus between enabled tabs (wrapping at the ends), Home/End jump to the first/last enabled tab — focus only, manual activation — and Enter/Space select through the button's native click. Tab icons arearia-hiddenin both layouts (the label carries the name), and a.checked()tab announces its state through visually hidden text (sk-common.completed) beside the hidden check icon. Horizontal layout keeps PrimeVue's own accessibility - builder validation:
TB.item()…build()throws on an empty or whitespace-only key;TB.tabs()…build()throws with no tabs added, and throws on a duplicate tab key in development builds (console.errors the same message in production instead, without de-duplicating);TB.tabs().queryParam()throws on an empty or whitespace-only name in development builds (console.errors in production and keeps the name already set); eachbuild()returns a fresh snapshot, so later.addTabs()calls on the same builder — or mutating the returned config — never affect an already-built config - multiple
SkTabsinstances on the same page need distinct.queryParam()values .permission()/.role()gating is presentation-only — authorize the underlying data server-side, and don't serialize hidden-tab data into the page's props
The active tab is exposed from the component via defineExpose, so parent components can access it when needed.
Tabs Inside a Dialog
A dialog is not a routable page, so syncing its tabs to the URL query string can fight the host page's own ?tab= param (or simply doesn't make sense). Call .syncUrl(false) and drive the active tab with v-model instead:
<script setup lang="ts">
import { ref } from 'vue';
const activeTab = ref('general');
const tabConfig = TB.tabs().syncUrl(false).addTabs(/* … */).build();
</script>
<template>
<AppDialog>
<SkTabs :config="tabConfig" v-model="activeTab">
<!-- ... -->
</SkTabs>
</AppDialog>
</template>
Good Fit
- settings screens
- profile screens
- long create/edit views split into logical sections