Skip to content

Peer views of one subject behind a strip of triggers, with the active tab in the URL.

import { BbTabs } from 'bitboss-ui';

On this page

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 once
  • BbCollapsible, if there is one region and the trigger belongs somewhere else
  • BbBreadcrumbs, if it is hierarchy
  • BbPagination, 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

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.

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

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.

diff
- <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

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.

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.

diff
- <BbTab :items="items" animate-x :animate-y="false" />
+ <BbTabs :items="items" disable-animate-y />

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

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.

diff
- <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.

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.

direction="vertical" stacks triggers beside the panes. Horizontal strips scroll when needed and keep the active trigger in view.

Release notes for January 2026.

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.

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

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.

v2v3
.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.