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
BbRadioGroupwithvariant="pill", if the strip picks a value a form or a filter submits rather than switching what the page shows - A wizard, if they are steps in a flow
- A page of their own, if the sections are genuinely separate destinations
Keep labels short. If the strip regularly overflows, split the page instead.
Passthrough
Hover or tap a part to outline it. The toggles turn on loading, errors and warnings where the component has them; only parts get a row.
Revenue is up 12% on last month.
Default
Pass items, bind the active value, and give every value a slot. That
slot is the panel.
Checkout redesign
Vantera web · ships 14 October
- Status
- On track, two open risks
- Owner
- Marta Villoresi
- Latency
- Down 18% since June
<template>
<div
class="grid max-w-md gap-3 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-4"
>
<div>
<p class="m-0 text-sm font-medium">Checkout redesign</p>
<p class="m-0 text-xs text-[color:var(--bb-text-muted)]">
Vantera web · ships 14 October
</p>
</div>
<!-- One slot per item value. The slot name is the value, and that slot is the panel. -->
<BbTabs v-model="active" :items="tabs">
<template #overview>
<dl class="m-0 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
<dt class="text-[color:var(--bb-text-muted)]">Status</dt>
<dd class="m-0">On track, two open risks</dd>
<dt class="text-[color:var(--bb-text-muted)]">Owner</dt>
<dd class="m-0">{{ members[0]?.fullName }}</dd>
<dt class="text-[color:var(--bb-text-muted)]">Latency</dt>
<dd class="m-0">Down 18% since June</dd>
</dl>
</template>
<template #activity>
<ul class="m-0 grid list-none gap-1 p-0 text-sm">
<li>Billing retries shipped on Tuesday.</li>
<li>Avatar upload timeout fixed the same day.</li>
<li>Address autocomplete moved to the next sprint.</li>
</ul>
</template>
<template #members>
<ul class="m-0 grid list-none gap-1 p-0 text-sm">
<li v-for="person in members" :key="person.id" class="flex justify-between gap-3">
<span>{{ person.fullName }}</span>
<span class="text-[color:var(--bb-text-muted)]">{{ person.jobTitle }}</span>
</li>
</ul>
</template>
</BbTabs>
</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>> = [
{ value: 'overview', label: 'Overview' },
{ value: 'activity', label: 'Activity' },
{ value: '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 rename the compiler cannot see, 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. Tab then lands on the panel.
-->
<BbTabs
v-model="active"
compact
:items="tabs"
aria-label="Orders by status"
>
<template v-for="status in tabs" :key="status.value" #[slotKey(status.value)]>
<p class="m-0 text-sm text-[color:var(--bb-text-muted)]">
{{ counts[status.value] }} orders are {{ status.label.toLowerCase() }}.
</p>
</template>
</BbTabs>
<p class="mt-3 text-sm text-[color:var(--bb-text-muted)]">
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, slotKey } 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 panel copy
// below free of non-null assertions.
type StatusTab = BbTabsItem<Status> & { label: string };
const tabs: StatusTab[] = [
{ value: 'pending', label: 'Pending' },
{ value: 'shipped', label: 'Shipped' },
{ value: 'delivered', label: 'Delivered' },
// A disabled tab stays visible, is skipped by the arrows, and can never
// become the default selection.
{ value: '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 panel. Disabled items remain visible and are skipped.
Tab lands on the panel itself. Because arrowing selects, do not fetch on every activation without caching.
With more than one strip on a page, name each with an aria-label on BbTabs
(aria-label="Invoice sections"): it lands on the tablist, which is what a
screen reader announces.
Rich labels
A label can carry more than text: a count or an icon.
- Invoice INV-1042 is overdue[email protected]
- Seat upgrade confirmed[email protected]
- Weekly product digest[email protected]
<template>
<div class="max-w-md">
<BbTabs v-model="active" compact :items="tabs">
<!--
`inbox:label` decorates one trigger. The slot name is the value in
kebab-case, and what goes in it is presentation only: the trigger is
already the control, so never nest a button or a link here.
-->
<template #inbox:label="{ label }">
<span class="inline-flex items-center gap-1.5">
<BbIcon icon="lucide:inbox" size="sm" />
{{ label }}
<BbBadge size="sm" variant="secondary">{{ unread }}</BbBadge>
</span>
</template>
<!-- `label` is the fallback for every trigger without its own slot. -->
<template #label="{ label, value }">
<span class="inline-flex items-center gap-1.5">
<BbIcon :icon="icons[value]" size="sm" />
{{ label }}
</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="grid text-sm">
<span class="font-medium">{{ thread.subject }}</span>
<span class="text-xs text-[color:var(--bb-text-muted)]">{{ thread.from }}</span>
</li>
</ul>
</template>
<template #sent>
<p class="m-0 text-sm text-[color:var(--bb-text-muted)]">
12 messages sent this week.
</p>
</template>
<template #archive>
<p class="m-0 text-sm text-[color:var(--bb-text-muted)]">
Everything older than 90 days lands here.
</p>
</template>
</BbTabs>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbBadge, BbIcon, BbTabs } from 'bitboss-ui';
import type { BbTabsItem } from 'bitboss-ui';
type Folder = 'inbox' | 'sent' | 'archive';
const tabs: Array<BbTabsItem<Folder>> = [
{ value: 'inbox', label: 'Inbox' },
{ value: 'sent', label: 'Sent' },
{ value: 'archive', label: 'Archive' },
];
const icons: Record<Folder, string> = {
inbox: 'lucide:inbox',
sent: 'lucide:send',
archive: 'lucide:archive',
};
const active = ref<Folder>('inbox');
const unread = 3;
const threads = [
{ id: 1, subject: 'Invoice INV-1042 is overdue', from: '[email protected]' },
{ id: 2, subject: 'Seat upgrade confirmed', from: '[email protected]' },
{ id: 3, subject: 'Weekly product digest', from: '[email protected]' },
];
</script>
Use <value>:label 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 the item's value in kebab-case: inReview, In Review and
in_review all become in-review, so the label slot is #in-review:label.
The value itself, and so the v-model, stays raw; only slots, DOM ids and
the URL use the slug.
For generated items, import slotKey from bitboss-ui and write
#[slotKey(item.value)]. A bare #[item.value] over a camelCase value names a
slot that never matches, and the panel stays empty.
Coming from v2key → value, and the label slot names changed
In v2 each item had a key; it is now value, and the v-model carries it.
The per-tab label slot was #label-<key> and the panel slot was the raw key.
Both now come from the value in kebab-case, the label slot as #<value>:label,
and its scope says label (it said text). A slot whose name no longer matches
renders nothing; npx bitboss-ui check --fix renames the item fields and the
slots when the items are in the same file.
- <template #label-in-review="{ item }">…</template>
+ <template #in-review:label="{ label }">…</template>
What happens on a switch
Only the active panel is mounted. Switching away unmounts it, and switching back mounts it fresh.
Lumen Sit-Stand Desk 160
€749.00 · FUR-DSK-1187
<template>
<div
class="grid max-w-md gap-3 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-4"
>
<div>
<p class="m-0 text-sm font-medium">{{ product.name }}</p>
<p class="m-0 text-xs text-[color:var(--bb-text-muted)]">
€{{ product.price.toFixed(2) }} · {{ product.sku }}
</p>
</div>
<!--
Each panel renders a DraftField, a stand-in for your own panel component
that keeps its draft in local state. The lazy panel loses the draft on
every switch away; the eager one stays mounted and keeps it.
-->
<BbTabs v-model="active" :items="tabs">
<template #description>
<p class="m-0 text-sm">
A sit-stand desk with a 160 cm top, dual motors and a memory
controller for three heights.
</p>
</template>
<template #question>
<DraftField
id="tabs-eager-question"
label="Your question (lazy panel)"
name="question"
placeholder="Does it fit a 180 cm top?"
/>
</template>
<template #review>
<DraftField
id="tabs-eager-review"
label="Your review (eager panel)"
name="review"
placeholder="Quiet motors, sturdy at full height."
/>
</template>
</BbTabs>
</div>
</template>
<script setup lang="ts">
import { defineComponent, h, ref } from 'vue';
import type { Component } from 'vue';
import { BbTabs, BbTextarea } from 'bitboss-ui';
import type { BbTabsItem } from 'bitboss-ui';
import { products } from '~/demo-data';
type Panel = 'description' | 'question' | 'review';
const product = products[1]!;
const tabs: Array<BbTabsItem<Panel>> = [
{ value: 'description', label: 'Description' },
{ value: 'question', label: 'Ask a question' },
// Only this panel is eager: mounted from the first render and never
// unmounted, so what you type survives a switch.
{ value: 'review', label: 'Write a review', eager: true },
];
const active = ref<Panel>('review');
// A panel component with its own state: the draft lives and dies with it.
const DraftField = defineComponent({
props: {
id: { type: String, required: true },
label: { type: String, required: true },
name: { type: String, required: true },
placeholder: { type: String, required: true },
},
setup(props) {
const draft = ref<string | null>(null);
// `as Component` keeps h() from expanding every overload of the field.
return () =>
h(BbTextarea as Component, {
...props,
compact: true,
rows: 3,
modelValue: draft.value,
'onUpdate:modelValue': (value: string | null) => (draft.value = value),
});
},
});
</script>
Type in both drafts, open Description, then come back: only the eager review
kept its text. Local form, scroll and chart state goes the same way, so keep it
in the parent or set eager on the affected item. An eager panel also
appears in server-rendered HTML, but still uses display: none while inactive.
The switch itself animates on two axes: the panels slide sideways and the container glides between panel heights, as in the Default demo. A mode toggle reads better without either.
<template>
<div class="max-w-md">
<!--
A mode switch, not a journey: sliding the panels 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 #rendered>
<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' | 'rendered';
const tabs: Array<BbTabsItem<Mode>> = [
{ value: 'write', label: 'Write' },
{ value: 'rendered', label: 'Rendered' },
];
const mode = ref<Mode>('write');
// The draft lives here, not inside the panel, so the default lazy panels 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 panels 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:
delete it. 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.
- In the URL
?demo-tab=open- Bound value
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">
{{ counts.open }} incidents are waiting for triage.
</p>
</template>
<!-- The slot name is the value in kebab-case: `inReview` is `#in-review`. -->
<template #in-review>
<p class="m-0 text-sm">
{{ counts.inReview }} incidents have an owner and a mitigation.
</p>
</template>
<template #closed>
<p class="m-0 text-sm">
{{ counts.closed }} incidents were resolved this quarter.
</p>
</template>
</BbTabs>
<dl
class="mt-4 mb-0 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm text-[color:var(--bb-text-muted)]"
>
<dt>In the URL</dt>
<!-- The URL carries `slotKey(value)`, the same kebab-case slug as the slot names. -->
<dd class="m-0"><code>?demo-tab={{ slotKey(view) }}</code></dd>
<dt>Bound value</dt>
<dd class="m-0"><code>{{ view }}</code></dd>
</dl>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbTabs, slotKey } from 'bitboss-ui';
import type { BbTabsItem } from 'bitboss-ui';
type View = 'open' | 'inReview' | 'closed';
const tabs: Array<BbTabsItem<View>> = [
{ value: 'open', label: 'Open' },
{ value: 'inReview', label: 'In review' },
{ value: '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');
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 same kebab-case slug as the slot names, never the raw
value: inReview is written as ?tab=in-review. The component writes and reads
the slug itself. When other code reads the parameter, such as a server
route, compare it with slotKey(value), not with the raw value. Values that
are already lowercase and hyphenated read the same either way.
Prerendered HTML holds 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. Back and Forward re-select the tab. For Inertia navigation, add href to items and
server for router.visit, and point query-key at the parameter your
links already use: the component writes ?<query-key>=<slug> onto the href. A server strip activates manually: arrow keys move focus
and Enter or Space opens the tab, so arrowing past a tab never starts a visit
(manual-activation set to false restores selection on arrow).
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
block makes the strip full width, direction="vertical" stacks it, and it
scrolls when the triggers don't fit.
€18,640 booked this month
<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.value" #[slotKey(tab.value)]>
<p class="m-0 text-sm">
<span class="text-lg font-semibold tabular-nums">€{{ revenue[tab.value] }}</span>
<span class="text-[color:var(--bb-text-muted)]"> booked {{ tab.label.toLowerCase() }}</span>
</p>
</template>
</BbTabs>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbTabs, slotKey } from 'bitboss-ui';
import type { BbTabsItem } from 'bitboss-ui';
type Range = 'week' | 'month' | 'quarter';
const tabs: Array<BbTabsItem<Range> & { label: string }> = [
{ value: 'week', label: 'This week' },
{ value: 'month', label: 'This month' },
{ value: '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.
General
Workspace name, default language and the region your data lives in.
<template>
<div class="max-w-lg rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-3">
<!--
The strip becomes a column beside the panels. `block` stretches every
trigger to the width of the column, so the rows line up like a nav.
-->
<BbTabs
v-model="section"
block
compact
direction="vertical"
:items="tabs"
>
<template v-for="tab in tabs" :key="tab.value" #[slotKey(tab.value)]>
<div class="grid gap-1 pt-1">
<p class="m-0 text-sm font-medium">{{ tab.label }}</p>
<p class="m-0 text-sm text-[color:var(--bb-text-muted)]">
{{ tab.summary }}
</p>
</div>
</template>
</BbTabs>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbTabs, slotKey } from 'bitboss-ui';
import type { BbTabsItem } from 'bitboss-ui';
type Section = 'general' | 'security' | 'tokens' | 'billing';
const tabs: Array<BbTabsItem<Section> & { label: string; summary: string }> = [
{ value: 'general', label: 'General', summary: 'Workspace name, default language and the region your data lives in.' },
{ value: 'security', label: 'Security', summary: 'Session length, two-factor enforcement and the SSO connection.' },
{ value: 'tokens', label: 'Access tokens', summary: 'Personal access tokens, their scopes and when each was last used.' },
{ value: 'billing', label: 'Billing', summary: 'Plan, seats and invoice history.' },
];
const section = ref<Section>('general');
</script>
direction="vertical" stacks triggers beside the panels, and block stretches
them to the width of the column. Horizontal strips scroll when needed and keep
the active trigger in view, including one opened from a deep link.
Release notes for January 2026.
<template>
<div class="max-w-md">
<BbTabs v-model="active" compact :items="tabs">
<!--
The two wrapper 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 #wrapper: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 #wrapper: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.value" #[slotKey(tab.value)]>
<p class="m-0 text-sm text-[color:var(--bb-text-muted)]">Release notes for {{ tab.label }}.</p>
</template>
</BbTabs>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbTabs, slotKey } from 'bitboss-ui';
import type { BbTabsItem } from 'bitboss-ui';
const months = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
];
const tabs: Array<BbTabsItem & { label: string }> = months.map((month) => ({
value: month.toLowerCase(),
label: `${month} 2026`,
}));
const active = ref('january');
</script>
wrapper:prepend and wrapper: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 panels
When the layout needs markup between the triggers and the panels, swap
BbTabs for BbTabsRoot, BbTabsList and BbTabsPanels.
Checkout redesign
Two open risks, both owned. Latency is down 18% since June.
<template>
<div class="max-w-md rounded-[var(--bb-radius)] border border-[color:var(--bb-border)]">
<!--
BbTabsRoot renders no element: it owns the state, and the list and the
panels sit anywhere below it. Here a status row lives between them,
which a plain BbTabs cannot hold. The root takes no `pt`; each piece
takes its own.
-->
<BbTabsRoot v-model="active" :items="tabs" variant="line">
<!-- The header draws the rule across the whole card, so the strip's
own baseline (`--list-border`, which stops after the last trigger)
is turned off on BbTabsList's root. -->
<div class="grid gap-3 border-b border-[color:var(--bb-border)] px-4 pt-4">
<p class="m-0 text-sm font-medium">Checkout redesign</p>
<BbTabsList :pt="{ root: { style: { '--list-border': 'transparent' } } }" />
</div>
<div
class="flex items-center justify-between gap-2 px-4 py-2 text-xs text-[color:var(--bb-text-muted)]"
>
<span class="min-w-0 truncate">{{ owner.fullName }} · ships 14 October</span>
<BbButton
class="shrink-0"
prepend:icon="lucide:link"
size="xs"
variant="ghost"
@click="copied = true"
>
{{ copied ? 'Link copied' : 'Copy link' }}
</BbButton>
</div>
<BbTabsPanels pt:root="px-4 pb-4 pt-1">
<template #overview>
<p class="m-0 text-sm">Two open risks, both owned. Latency is down 18% since June.</p>
</template>
<template #activity>
<p class="m-0 text-sm">Billing retries shipped Tuesday; the avatar upload timeout is fixed.</p>
</template>
<template #members>
<p class="m-0 text-sm">14 editors, 23 viewers and 3 billing administrators.</p>
</template>
</BbTabsPanels>
</BbTabsRoot>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbTabsList, BbTabsPanels, BbTabsRoot } from 'bitboss-ui';
import type { BbTabsItem } from 'bitboss-ui';
import { users } from '~/demo-data';
type Section = 'overview' | 'activity' | 'members';
const tabs: Array<BbTabsItem<Section>> = [
{ value: 'overview', label: 'Overview' },
{ value: 'activity', label: 'Activity' },
{ value: 'members', label: 'Members' },
];
const active = ref<Section>('overview');
const owner = users[0]!;
const copied = ref(false);
</script>
BbTabsRoot takes the props of BbTabs and renders no element, so the list
and the panels can sit anywhere below it. Its variant is only the default: a
BbTabsList can take its own. It takes no pt either: style the pieces with
their own (pt:root, pt:list, pt:item, pt:indicator on BbTabsList, where
root is what BbTabs calls wrapper; pt:root, pt:panel on
BbTabsPanels). The demo runs the card's own rule under the strip and turns the
strip's baseline off with --list-border: transparent on the list's root.
BbTabsRoot's default slot hands you current, goTo, isFirst and isLast,
enough for a section title or Previous and Next buttons without a ref. Use plain
BbTabs when the strip sits directly over its panels.
To steer a group from outside its tree, there is a composable instead:
Two sessions have no second factor.
Workspace name and default region.
Active section: General
<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 text-[color:var(--bb-text-muted)]">Workspace name and default region.</p>
</template>
<template #security>
<p class="m-0 text-sm text-[color:var(--bb-text-muted)]">
Session length, two-factor enforcement and the SSO connection.
</p>
</template>
<template #billing>
<p class="m-0 text-sm text-[color:var(--bb-text-muted)]">Plan, seats and invoice history.</p>
</template>
</BbTabs>
<p class="m-0 text-sm text-[color:var(--bb-text-muted)]">
Active section: {{ labelOf(current) }}
</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>> = [
{ value: 'general', label: 'General' },
{ value: 'security', label: 'Security' },
{ value: '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');
const labelOf = (value: Section | null | undefined) =>
tabs.find((tab) => tab.value === value)?.label ?? 'General';
</script>
Pass useBbTabsContext(id) the same explicit id as the tabs; generated ids
are not addressable. The registry is client-only, so wait for isReady before
calling the handle.
Variants and strip styling
variant picks the strip's look and nothing else: pill (the default) for
views of one list, line for the sections of a record, none for a look you
build yourself through pt.
pill for views of one list
Cards grouped by status: 4 to do, 3 in progress, 9 done.
line for the sections of one record
Checkout redesign, owned by Marta Villoresi, ships 14 October.
none for a look of your own
1,284 orders, up 6% on last week.
<template>
<div class="grid max-w-lg gap-6">
<section v-for="look in looks" :key="look.variant" class="grid gap-2">
<p class="m-0 text-sm">
<code>{{ look.variant }}</code>
<span class="text-[color:var(--bb-text-muted)]"> for {{ look.use }}</span>
</p>
<!--
`variant` changes the paint only: items, v-model, keyboard and
panels behave the same under all three. `none` paints nothing, so
its look is the strip's own tokens, passed inline through `pt`.
-->
<BbTabs
:items="look.items"
:pt="look.variant === 'none' ? chips : undefined"
:variant="look.variant"
>
<template #label="{ label, value }">
<span class="inline-flex items-center gap-1.5">
{{ label }}
<BbBadge v-if="counts[value]" size="sm" variant="secondary">{{ counts[value] }}</BbBadge>
</span>
</template>
<template v-for="item in look.items" :key="item.value" #[slotKey(item.value)]>
<p class="m-0 text-sm text-[color:var(--bb-text-muted)]">{{ item.panel }}</p>
</template>
</BbTabs>
</section>
</div>
</template>
<script setup lang="ts">
import { BbBadge, BbTabs, slotKey } from 'bitboss-ui';
import type { BbTabsItem, BbTabsProps } from 'bitboss-ui';
import { users } from '~/demo-data';
// Counts ride in the `label` slot, keyed by tab.
const counts: Record<string, number> = { activity: 12, members: 4 };
// A filter row built on `none`: rounded triggers and a soft wash under the
// active one (the indicator still slides). Custom properties in `style` land
// inline on the strip, so they win with no stylesheet at all.
const chips: BbTabsProps['pt'] = {
wrapper: {
style: {
'--trigger-px': '0.75rem',
'--trigger-gap': '0.25rem',
'--trigger-r': '999px',
'--indicator-r': '999px',
'--indicator-bg': 'color-mix(in oklab, var(--bb-text) 8%, transparent)',
'--list-fg': 'var(--bb-text-muted)',
'--hover-fg': 'var(--bb-text)',
'--active-fg': 'var(--bb-text)',
},
},
};
type Look = {
variant: 'pill' | 'line' | 'none';
use: string;
items: Array<BbTabsItem & { panel: string }>;
};
const looks: Look[] = [
{
variant: 'pill',
use: 'views of one list',
items: [
{ value: 'board', label: 'Board', panel: 'Cards grouped by status: 4 to do, 3 in progress, 9 done.' },
{ value: 'table', label: 'Table', panel: 'One row per task, sortable by due date.' },
{ value: 'calendar', label: 'Calendar', panel: 'Due dates on a month grid.' },
],
},
{
variant: 'line',
use: 'the sections of one record',
items: [
{ value: 'overview', label: 'Overview', panel: `Checkout redesign, owned by ${users[0]!.fullName}, ships 14 October.` },
{ value: 'activity', label: 'Activity', panel: '12 events this week, newest first.' },
{ value: 'members', label: 'Members', panel: 'Four people can edit this project.' },
],
},
{
variant: 'none',
use: 'a look of your own',
items: [
{ value: 'week', label: 'This week', panel: '1,284 orders, up 6% on last week.' },
{ value: 'month', label: 'This month', panel: '5,310 orders so far in September.' },
{ value: 'year', label: 'This year', panel: '48,902 orders since January.' },
],
},
];
</script>
A look of your own starts from none and the strip's tokens (--trigger-px,
--indicator-bg, --active-fg…). Pass them in pt.wrapper.style: they land
inline on the strip and win with no stylesheet. Set on an ancestor, they lose to
the component defaults. The sliding marker is an element of its own, reached with
pt:indicator.
Coming from v2every class was renamed
Your old rules stop matching and the shipped styling shows through;
npx bitboss-ui check reports each leftover .bb-* class in your stylesheets. The single block .bb-tab became three: .bb-tabs on the root,
.bb-tabs-list on the strip, .bb-tabs-panels around the panels.
| v2 | v3 |
|---|---|
.bb-tab | .bb-tabs |
.bb-tab__label-boundary | .bb-tabs-list |
.bb-tab__label-container | .bb-tabs-list__list |
.bb-tab__btn | .bb-tabs-list__item |
.bb-tab__btn--active | .bb-tabs-list__item--active |
.bb-tab__label | .bb-tabs__trigger-label |
.bb-tab__panes-container | .bb-tabs-panels |
.bb-tab__pane | .bb-tabs__panel |
rg -n 'bb-tab(__|--)|\.bb-tab\b' finds every v2 name and no v3 one.
The strip also holds the wrapper:prepend and wrapper:append slots, so
replace broad child selectors with .bb-tabs-list__list.