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 BbRadioGroup with variant="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

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.

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.

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.

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

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.

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.

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.

In the URL
?demo-tab=open
Bound value
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 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.

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

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.

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.

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

Marta Villoresi · ships 14 October

Two open risks, both owned. Latency is down 18% since June.

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

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.

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.

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