Use it for
Use BbTabs to divide one screen into peer sections a reader flips between
without leaving the page: a project's Overview, Activity, Members. Exactly one is
visible at a time.
Use something else when
BbAccordion, if the reader may want two sections open at onceBbCollapsible, if there is one region and the trigger belongs somewhere elseBbBreadcrumbs, if it is hierarchyBbPagination, if you are paging a result set- A wizard, if they are steps in a flow
- A page of their own, if the sections are genuinely separate destinations
Pass Through
Hover or tap a part to outline it. Toggles flip loading, errors and warnings when the component has them — only parts highlight.
Overview pane.
Keep labels short. If the strip regularly overflows, split the page instead.
Default
Pass items, bind the active key, and give every key a slot. That slot is the
pane.
Checkout latency is down 18% since June. Two open risks, both owned.
Active key: overview
<template>
<div class="max-w-md">
<!-- One slot per item key. The slot name is the key. -->
<BbTabs v-model="active" :items="tabs">
<template #overview>
<p class="m-0 text-sm opacity-70">
Checkout latency is down 18% since June. Two open risks, both owned.
</p>
</template>
<template #activity>
<p class="m-0 text-sm opacity-70">
Billing retries shipped on Tuesday; the avatar upload timeout was
fixed the same day.
</p>
</template>
<template #members>
<ul class="m-0 grid list-none gap-1 p-0 text-sm opacity-70">
<li v-for="person in members" :key="person.id">
{{ person.fullName }} — {{ person.jobTitle }}
</li>
</ul>
</template>
</BbTabs>
<p class="mt-3 text-sm opacity-70">Active key: {{ active }}</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbTabs } from 'bitboss-ui';
import type { BbTabsItem } from 'bitboss-ui';
import { users } from '~/demo-data';
type Section = 'overview' | 'activity' | 'members';
const tabs: Array<BbTabsItem<Section>> = [
{ key: 'overview', label: 'Overview' },
{ key: 'activity', label: 'Activity' },
{ key: 'members', label: 'Members' },
];
// Drop the v-model entirely and the first enabled tab is selected for you.
const active = ref<Section>('overview');
const members = users.slice(0, 4);
</script>
Without v-model, the first enabled tab is selected. If every tab is disabled,
the strip stays inert.
Coming from v2BbTab → BbTabs
The tag changed, so the compiler tells you here. The old type names survive as deprecated re-exports. Everything else that changed is a silent rename, flagged in the section where you meet it.
Keyboard and roles
A tab strip is one widget with one tab stop, not three buttons in a row.
4 orders are pending.
Tab moves focus into the strip, then out of it to the panel — one stop each, not one per trigger.
<template>
<div class="max-w-md">
<!--
The strip is one tab stop. Focus a trigger, then use the arrows: the
selection moves with the focus, Home and End jump to the ends, and the
disabled tab is skipped in both directions.
-->
<BbTabs v-model="active" :items="tabs">
<template v-for="status in tabs" :key="status.key" #[status.key]>
<p class="m-0 text-sm opacity-70">
{{ counts[status.key] }} orders are {{ status.label.toLowerCase() }}.
</p>
</template>
</BbTabs>
<p class="mt-3 text-sm opacity-70">
Tab moves focus into the strip, then out of it to the panel — one stop
each, not one per trigger.
</p>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbTabs } from 'bitboss-ui';
import type { BbTabsItem } from 'bitboss-ui';
import { orders } from '~/demo-data';
type Status = 'pending' | 'shipped' | 'delivered' | 'refunded';
// `label` is optional on BbTabsItem; narrowing it here keeps the pane copy
// below free of non-null assertions.
type StatusTab = BbTabsItem<Status> & { label: string };
const tabs: StatusTab[] = [
{ key: 'pending', label: 'Pending' },
{ key: 'shipped', label: 'Shipped' },
{ key: 'delivered', label: 'Delivered' },
// A disabled tab stays visible, is skipped by the arrows, and can never
// become the default selection.
{ key: 'refunded', label: 'Refunded', disabled: true },
];
const active = ref<Status>('pending');
const counts = computed<Record<Status, number>>(() => ({
pending: orders.filter((order) => order.status === 'pending').length,
shipped: orders.filter((order) => order.status === 'shipped').length,
delivered: orders.filter((order) => order.status === 'delivered').length,
refunded: orders.filter((order) => order.status === 'refunded').length,
}));
</script>
The component wires the tab roles and relationships. Tab enters the strip once; arrow keys move and select, Home and End jump to the edges, and Tab continues to the active pane. Disabled items remain visible and are skipped.
Because arrowing selects, do not fetch on every activation without caching.
Rich labels
A label can carry more than text: a count, a status dot, an icon.
- Invoice INV-1042 is overdue — billing@acme.co
- Seat upgrade confirmed — hello@bitboss.io
- Weekly product digest — product@northwind.io
<template>
<div class="max-w-md">
<BbTabs v-model="active" compact :items="tabs">
<!--
`label:inbox` decorates one trigger. The slot name is the normalized
key, and what goes in it is presentation only — the trigger is
already the control, so never nest a button or a link here.
-->
<template #label:inbox="{ text }">
<span class="inline-flex items-center gap-1.5">
{{ text }}
<BbBadge size="sm" variant="secondary">{{ unread }}</BbBadge>
</span>
</template>
<template #inbox>
<ul class="m-0 grid list-none gap-2 p-0">
<li v-for="thread in threads" :key="thread.id" class="text-sm">
<span class="font-medium">{{ thread.subject }}</span>
<span class="opacity-70"> — {{ thread.from }}</span>
</li>
</ul>
</template>
<template #sent>
<p class="m-0 text-sm opacity-70">12 messages sent this week.</p>
</template>
<template #archive>
<p class="m-0 text-sm opacity-70">
Everything older than 90 days lands here.
</p>
</template>
</BbTabs>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbBadge, BbTabs } from 'bitboss-ui';
import type { BbTabsItem } from 'bitboss-ui';
type Folder = 'inbox' | 'sent' | 'archive';
const tabs: Array<BbTabsItem<Folder>> = [
{ key: 'inbox', label: 'Inbox' },
{ key: 'sent', label: 'Sent' },
{ key: 'archive', label: 'Archive' },
];
const active = ref<Folder>('inbox');
const unread = 12;
const threads = [
{ id: 1, subject: 'Invoice INV-1042 is overdue', from: 'billing@acme.co' },
{ id: 2, subject: 'Seat upgrade confirmed', from: 'hello@bitboss.io' },
{ id: 3, subject: 'Weekly product digest', from: 'product@northwind.io' },
];
</script>
Use label:<key> for one tab and label for every tab. Keep these slots
presentational: a nested button or link creates an invalid interactive control.
Slot names are normalized: spaces and punctuation become _ and everything
is lowercased. inReview becomes inreview, In Review becomes in_review.
The key and the v-model value stay raw; only slots, DOM ids and
the URL use the slug.
For generated items, import slotKey and write #[slotKey(item.key)].
Coming from v2the label slot names changed
In v2 the per-tab label slot was #label-<key> and the pane slot was the raw
key. A slot whose name no longer matches renders nothing and reports nothing.
- <template #label-in-review="{ item }">…</template>
+ <template #label:in_review="{ text }">…</template>
When a pane renders
Only the active pane is mounted. Switching away unmounts it, and switching back mounts it fresh.
Lumen Sit-Stand Desk 160
A sit-stand desk with a 160 cm top, dual motors and a memory controller for three heights.
- SKU
- FUR-DSK-1187
- Category
- Furniture
- Price
- 749 EUR
- Updated
- 2026-08-21
<template>
<div class="max-w-md">
<p class="m-0 mb-3 text-sm font-medium">{{ product.name }}</p>
<BbTabs v-model="active" :items="tabs">
<template #description>
<p class="m-0 text-sm opacity-70">
A sit-stand desk with a 160 cm top, dual motors and a memory
controller for three heights.
</p>
</template>
<template #specifications>
<dl class="m-0 grid grid-cols-2 gap-x-4 gap-y-1 text-sm opacity-70">
<dt>SKU</dt>
<dd class="m-0">{{ product.sku }}</dd>
<dt>Category</dt>
<dd class="m-0">{{ product.category }}</dd>
<dt>Price</dt>
<dd class="m-0">{{ product.price }} {{ product.currency }}</dd>
<dt>Updated</dt>
<dd class="m-0">{{ product.updatedAt }}</dd>
</dl>
</template>
<template #reviews>
<p class="m-0 text-sm opacity-70">
Rated {{ product.rating }} out of 5 across 214 reviews.
</p>
</template>
</BbTabs>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbTabs } from 'bitboss-ui';
import type { BbTabsItem } from 'bitboss-ui';
import { products } from '~/demo-data';
type Pane = 'description' | 'specifications' | 'reviews';
const product = products[1]!;
const tabs: Array<BbTabsItem<Pane>> = [
{ key: 'description', label: 'Description' },
// Only this pane is eager: its table is the part a search engine should
// find, and it is in the page source whether or not the tab is ever opened.
{ key: 'specifications', label: 'Specifications', eager: true },
{ key: 'reviews', label: 'Reviews' },
];
const active = ref<Pane>('description');
</script>
Inactive panes unmount, so local form, scroll, and chart state is lost. Keep
that state in the parent or set eager on the affected item. An eager pane also
appears in server-rendered HTML, but still uses display: none while inactive.
Switch animation
Switching animates on two axes: the panes slide horizontally, and the container animates its height between panes of different size.
<template>
<div class="max-w-md">
<!--
A mode switch, not a journey: sliding the panes sideways would suggest
the two views sit next to each other. Both axes off makes the swap
instant.
-->
<BbTabs
v-model="mode"
compact
disable-animate-x
disable-animate-y
:items="tabs"
>
<template #write>
<BbTextarea
id="tabs-composer-body"
v-model="draft"
compact
label="Comment"
name="body"
:rows="4"
/>
</template>
<template #preview>
<div
class="min-h-[92px] rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-3 text-sm"
>
<p v-for="(line, index) in lines" :key="index" class="m-0">
{{ line }}
</p>
</div>
</template>
</BbTabs>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbTabs, BbTextarea } from 'bitboss-ui';
import type { BbTabsItem } from 'bitboss-ui';
type Mode = 'write' | 'preview';
const tabs: Array<BbTabsItem<Mode>> = [
{ key: 'write', label: 'Write' },
{ key: 'preview', label: 'Preview' },
];
const mode = ref<Mode>('write');
// The draft lives here, not inside the pane, so the default lazy panes have
// nothing to lose when you switch back and forth.
const draft = ref<string | null>(
'Shipping the retry fix today.\nThe timeout regression is next.'
);
const lines = computed(() => (draft.value ?? '').split('\n'));
</script>
disable-animate-x removes the slide. disable-animate-y removes height
animation, which helps when panes run their own transitions or virtualization.
Reduced-motion preferences are respected automatically.
Coming from v2animateX / animateY inverted
The compiler cannot see this one. A bare animate-x was already the default and
simply goes; an :animate-y="false" becomes disable-animate-y. Left as they
are, both fall into $attrs and land on the root element as plain attributes, so
the animation you switched off comes back. A development build warns by name; a
production build does not.
- <BbTab :items="items" animate-x :animate-y="false" />
+ <BbTabs :items="items" disable-animate-y />
The active tab in the URL
navigation keeps the active tab in the URL query, which makes it deep-linkable
and lets it survive a reload.
6 incidents are waiting for triage.
Switch tabs and watch the address bar. Query: ?demo-tab=open
Bound key, unchanged: open
<template>
<div class="max-w-md">
<!--
`navigation` writes the active tab into the query, `query-key` names
the parameter, and `replace` keeps Back pointing at the page you came
from instead of at your last tab click.
-->
<BbTabs
v-model="view"
compact
:items="tabs"
navigation
query-key="demo-tab"
replace
>
<template #open>
<p class="m-0 text-sm opacity-70">
{{ counts.open }} incidents are waiting for triage.
</p>
</template>
<!-- The slot name is the normalized key: `inReview` becomes `inreview`. -->
<template #inreview>
<p class="m-0 text-sm opacity-70">
{{ counts.inReview }} incidents have an owner and a mitigation.
</p>
</template>
<template #closed>
<p class="m-0 text-sm opacity-70">
{{ counts.closed }} incidents were resolved this quarter.
</p>
</template>
</BbTabs>
<p class="mt-3 text-sm opacity-70">
Switch tabs and watch the address bar. Query:
<code>?demo-tab={{ slug }}</code>
</p>
<p class="mt-1 text-sm opacity-70">
Bound key, unchanged: <code>{{ view }}</code>
</p>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbTabs } from 'bitboss-ui';
import type { BbTabsItem } from 'bitboss-ui';
type View = 'open' | 'inReview' | 'closed';
const tabs: Array<BbTabsItem<View>> = [
{ key: 'open', label: 'Open' },
{ key: 'inReview', label: 'In review' },
{ key: 'closed', label: 'Closed' },
];
// This page is prerendered with no query, so the static HTML always carries
// this tab. The URL only takes over once the browser has the page.
const view = ref<View>('open');
// The URL carries the normalized slug, never the raw key.
const slug = computed(() => view.value.toLowerCase());
const counts = { open: 6, inReview: 3, closed: 41 };
</script>
query-key names the parameter and defaults to tab. Pair navigation with
replace, or every tab change adds a browser-history entry.
The URL carries the normalized slug, never the raw key. The component
round-trips this itself; what breaks is anything else reading that parameter.
A server matching ?tab= against camelCase keys silently never matches. Compare
against the normalized form, or keep URL-synced tab keys lowercase.
Prerendered HTML contains the fallback tab because no query exists at build
time. Choose a useful fallback and mark deep-linked content eager when it must
appear in that HTML.
For Inertia navigation, add href to items and server for router.visit.
Point query-key at the parameter your links already use.
Coming from v2querykey → queryKey
The all-lowercase spelling type-checks, falls through to $attrs, and the URL
sync quietly moves to the default ?tab=. A development build warns; a
production build does not. v2 deep links carrying a raw value also fall back to
the default tab. Add a redirect if those links matter.
- <BbTab :items="items" navigation querykey="section" />
+ <BbTabs :items="items" navigation query-key="section" replace />
Width, direction and overflow
The strip has three shapes and one behaviour when it runs out of room.
18,640 € booked over the This month window.
<template>
<div class="max-w-md">
<!--
`block` stretches the strip to its container and gives every trigger the
width of the widest one, so the row reads as a segmented control.
`compact` drops the whole scale a notch to match compact fields.
-->
<BbTabs v-model="range" block compact :items="tabs">
<template v-for="tab in tabs" :key="tab.key" #[tab.key]>
<p class="m-0 text-sm opacity-70">
{{ revenue[tab.key] }} € booked over the {{ tab.label }} window.
</p>
</template>
</BbTabs>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbTabs } from 'bitboss-ui';
import type { BbTabsItem } from 'bitboss-ui';
type Range = 'week' | 'month' | 'quarter';
const tabs: Array<BbTabsItem<Range> & { label: string }> = [
{ key: 'week', label: 'This week' },
{ key: 'month', label: 'This month' },
{ key: 'quarter', label: 'This quarter' },
];
const range = ref<Range>('month');
const revenue: Record<Range, string> = {
week: '4,120',
month: '18,640',
quarter: '52,905',
};
</script>
block makes horizontal triggers equal width. compact aligns the strip with
compact fields.
Coming from v2block replaces your full-width class
Delete the class your v2 project used for full width. A leftover rule can
override --block without warning.
Workspace name, default language and the region your data lives in.
<template>
<div class="max-w-lg">
<!--
The strip becomes a column beside the panes. `--max-w` caps its width —
a maximum, not a width, so short labels keep their natural size and long
ones ellipse instead of widening the column.
-->
<BbTabs
v-model="section"
compact
direction="vertical"
:items="tabs"
style="--max-w: 150px"
>
<template #general>
<p class="m-0 text-sm opacity-70">
Workspace name, default language and the region your data lives in.
</p>
</template>
<template #security>
<p class="m-0 text-sm opacity-70">
Session length, two-factor enforcement and the SSO connection.
</p>
</template>
<template #tokens>
<p class="m-0 text-sm opacity-70">
Personal access tokens, their scopes and when each was last used.
</p>
</template>
</BbTabs>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbTabs } from 'bitboss-ui';
import type { BbTabsItem } from 'bitboss-ui';
type Section = 'general' | 'security' | 'tokens';
const tabs: Array<BbTabsItem<Section>> = [
{ key: 'general', label: 'General' },
{ key: 'security', label: 'Security' },
{ key: 'tokens', label: 'Personal access tokens' },
];
const section = ref<Section>('general');
</script>
direction="vertical" stacks triggers beside the panes. Horizontal strips
scroll when needed and keep the active trigger in view.
Release notes for January 2026.
<template>
<div class="max-w-md">
<BbTabs v-model="active" compact :items="tabs">
<!--
The two header slots sit outside the scrollable list. Bind each
arrow's `disabled` to the matching can-scroll flag so it dims at the
end of the strip, and give an icon-only button an accessible name.
-->
<template #header:prepend="{ canScrollLeft, scroll }">
<BbButton
aria-label="Scroll tabs left"
class="mr-1 shrink-0"
:disabled="!canScrollLeft"
icon="lucide:chevron-left"
size="sm"
variant="ghost"
@click="scroll('left')"
/>
</template>
<template #header:append="{ canScrollRight, scroll }">
<BbButton
aria-label="Scroll tabs right"
class="ml-1 shrink-0"
:disabled="!canScrollRight"
icon="lucide:chevron-right"
size="sm"
variant="ghost"
@click="scroll('right')"
/>
</template>
<template v-for="tab in tabs" :key="tab.key" #[tab.key]>
<p class="m-0 text-sm opacity-70">Release notes for {{ tab.label }}.</p>
</template>
</BbTabs>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbTabs } from 'bitboss-ui';
import type { BbTabsItem } from 'bitboss-ui';
const months = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
];
// Keys are already lowercase slugs, so `#[tab.key]` below names the pane slot
// correctly. A key of `v3-1` would need the slot `v3_1`.
const tabs: Array<BbTabsItem & { label: string }> = months.map((month) => ({
key: month.toLowerCase(),
label: `${month} 2026`,
}));
const active = ref('january');
</script>
header:prepend and header:append render before and after the scrollable list,
outside it. Each receives canScrollLeft, canScrollRight, isScrolling and a
scroll('left' | 'right') callback that pages the strip by about 80% of its
width. Bind each arrow's disabled to the matching flag. These buttons are your
markup, so an icon-only arrow needs an aria-label you write yourself.
Splitting the strip from the panes
When the layout needs arbitrary markup between the triggers and the panes, swap
BbTabs for the renderless BbTabsRoot.
Two owners, one open risk, latency down 18% since June.
<template>
<div class="max-w-md">
<!--
BbTabsRoot renders nothing itself: it owns the state and publishes it,
so the list and the panes can sit anywhere below it — here with a
toolbar and a rule between them.
-->
<BbTabsRoot v-model="active" compact :items="tabs">
<template #default="{ goTo, isFirst, isLast }">
<div class="flex items-center justify-between gap-2">
<BbTabsList class="min-w-0 flex-1" />
<div class="flex shrink-0 gap-1">
<BbButton
:disabled="isFirst"
size="sm"
variant="outline"
@click="goTo('previous')"
>
Previous
</BbButton>
<BbButton
:disabled="isLast"
size="sm"
variant="outline"
@click="goTo('next')"
>
Next
</BbButton>
</div>
</div>
<div class="my-3 border-t border-[color:var(--bb-border)]" />
<BbTabsPanes>
<template #overview>
<p class="m-0 text-sm opacity-70">
Two owners, one open risk, latency down 18% since June.
</p>
</template>
<template #activity>
<p class="m-0 text-sm opacity-70">
Billing retries shipped Tuesday; avatar upload timeout fixed.
</p>
</template>
<template #members>
<p class="m-0 text-sm opacity-70">
14 editors, 23 viewers, 3 billing administrators.
</p>
</template>
</BbTabsPanes>
</template>
</BbTabsRoot>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbTabsList, BbTabsPanes, BbTabsRoot } from 'bitboss-ui';
import type { BbTabsItem } from 'bitboss-ui';
type Section = 'overview' | 'activity' | 'members';
const tabs: Array<BbTabsItem<Section>> = [
{ key: 'overview', label: 'Overview' },
{ key: 'activity', label: 'Activity' },
{ key: 'members', label: 'Members' },
];
const active = ref<Section>('overview');
</script>
BbTabsRoot owns the same state while BbTabsList and BbTabsPanes can sit
anywhere below it. Use plain BbTabs when the strip sits directly over its
panes.
To steer a group from outside its tree, there is a composable instead:
Two sessions have no second factor.
Workspace name and default region.
Reported by the handle: not mounted yet
<template>
<div class="flex max-w-md flex-col gap-3">
<!--
This banner is not inside the tabs, so there is no context to inject and
no template ref to reach for. `useBbTabsContext` finds the group by the
same id the component was given.
-->
<div
class="flex items-center justify-between gap-3 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-3"
>
<p class="m-0 text-sm">Two sessions have no second factor.</p>
<BbButton size="sm" variant="outline" @click="select('security')">
Review
</BbButton>
</div>
<BbTabs id="workspace-settings" v-model="section" compact :items="tabs">
<template #general>
<p class="m-0 text-sm opacity-70">Workspace name and default region.</p>
</template>
<template #security>
<p class="m-0 text-sm opacity-70">
Session length, two-factor enforcement and the SSO connection.
</p>
</template>
<template #billing>
<p class="m-0 text-sm opacity-70">Plan, seats and invoice history.</p>
</template>
</BbTabs>
<p class="m-0 text-sm opacity-70">
Reported by the handle: {{ current ?? 'not mounted yet' }}
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbTabs, useBbTabsContext } from 'bitboss-ui';
import type { BbTabsItem } from 'bitboss-ui';
type Section = 'general' | 'security' | 'billing';
const tabs: Array<BbTabsItem<Section>> = [
{ key: 'general', label: 'General' },
{ key: 'security', label: 'Security' },
{ key: 'billing', label: 'Billing' },
];
const section = ref<Section>('general');
// The id must be the one you passed to the component — generated ids are not
// addressable. The handle is empty on the server and until the tabs mount.
const { current, select } = useBbTabsContext<Section>('workspace-settings');
</script>
useBbTabsContext(id) controls a mounted group from outside its tree. Pass an
explicit matching id; generated ids are not addressable. The registry is
client-only, so wait for isReady before calling the handle.
Styling the strip
Global tokens set the scale. Set strip-specific variables such as --list-bg,
--trigger-px, and --pill-bg directly on .bb-tabs-list; ancestor values
lose to the component defaults.
Coming from v2every class was renamed
Nothing warns: your old rules simply stop matching and the shipped styling shows
through. The single block .bb-tab became three: .bb-tabs on the root,
.bb-tabs-list on the strip, .bb-tabs-panes on the pane wrapper.
| v2 | v3 |
|---|---|
.bb-tab | .bb-tabs |
.bb-tab__label-boundary | .bb-tabs-list |
.bb-tab__label-container | .bb-tabs-list__tablist |
.bb-tab__btn | .bb-tabs__trigger |
.bb-tab__btn--active | .bb-tabs__trigger--active |
.bb-tab__label | .bb-tabs__trigger-label |
.bb-tab__panes-container | .bb-tabs-panes |
.bb-tab__pane | .bb-tabs__pane |
rg -n 'bb-tab(__|--)|\.bb-tab\b' finds every v2 name and no v3 one.
The list wrapper can also contain prepend and append slots, so replace broad
child selectors with .bb-tabs-list__tablist.