Skip to content

Passthrough

Reach a part inside any component, such as its icon, its field box or a table row, with classes, styles or attributes.

On this page

A variant or a token covers most looks. When neither does, pt reaches inside the component. Every component names its parts (root, icon, box, row, …) and its states (loading, errors, selected, …). You bind a class list to a part with a prop:

vue
<BbButton
    prepend:icon="lucide:save"
    pt:icon="text-emerald-600"
>
    Save
</BbButton>

The grammar is the same on every component, and it is typed and linted.

Reach a part

Write pt:<part> for the resting look and pt:<part>:<state> for what changes while a state is on. Edit the address below. The icon is faint while the field is empty and turns full ink, the primary colour on this site, once it holds a value. Break the address and leave the field: the icon turns red.

An incomplete address switches on both hasValue and errors. The red wins because errors comes later in the library's state order. The button uses the same grammar: pt:text:loading dims its label while the invite sends.

To see a component's parts, open its page. The Passthrough panel near the top outlines each part when you point at its name, as on BbSelect. The API page lists the parts and the component's states on one pt:<part> row, as on BbButton.

A misspelt part in a template attribute is an ESLint error (bitboss-ui/no-unknown-attributes), and a type error with vueCompilerOptions.strictTemplates on. An inline :pt="{ … }" is type-checked too. A pt object built in a const escapes both, so in dev a key that names nothing logs one warning with the component's vocabulary:

text
bitboss-ui | BbButton: passthrough key "icno" in the `pt` object
matches nothing and is ignored. "icno" is not a part of BbButton.
Parts: root, icon, spinner, text.

When pt is the wrong tool

Reach for pt after a variant and a token, not before. That is rule 2 in Design language.

  • A look several places share is a variant. Pick the closest one first (ghost for no field chrome, none for a blank base), then use pt for what is left. A rebuilt ghost loses the specificity fight the variant exists to win:
    vue
    <!-- Don't -->
    <BbTextInput pt:box="bg-transparent border-transparent" />
    <!-- Do -->
    <BbTextInput variant="ghost" />
    
  • A size is a token. pt:icon="size-6" on a button resizes the icon but not the spinner parked over it. Set the token on the root instead:
    vue
    <BbButton :pt:root="{ style: { '--icon-size': '22px' } }" />
    
  • A field's resting border is a token. Set its variable and the error frame still paints over it. A plain border-emerald-600 hides the error frame, and the ESLint rule bitboss-ui/no-box-border-color flags it. The state borders are classes:
    vue
    <BbTextInput
        pt:box="[--border-color:var(--color-emerald-600)]"
        pt:box:errors="border-red-800"
    />
    
  • A colour that changes on hover or validation is a variable. A class on BbRating's stars, pt:item="text-amber-500", freezes them: no hover preview, no error tint. Set the accent on the root and the rating keeps choosing which colour to show:
    vue
    <BbRating pt:root="[--accent:var(--color-amber-500)]" />
    
  • A component whose only part is root (BbAsterisk, BbIcon, BbSmoothHeight, BbSpinner) takes plain class and style.
  • Behaviour goes through props and events. A listener in pt runs after the component's own and cannot cancel it. Icons and spinners ignore the pointer, so an onClick on pt:icon never fires.

Accessibility needs nothing from pt. Names, roles, live regions and focus order are wired inside every component.

Every form a pt value takes

The name says where, the value says what. These are the forms on an instance:

vue
<!-- A part -->
<BbButton pt:icon="text-red-500" />

<!-- A part and a state -->
<BbButton pt:icon:loading="opacity-50" />

<!-- An object value: class, style and attrs -->
<BbButton
    :pt:icon="{ class: 'shrink-0', attrs: { 'data-test': 'icon' } }"
/>

<!-- The object prop: the same keys without the prefix -->
<BbButton
    :pt="{ icon: 'text-red-500', 'icon:loading': 'opacity-50' }"
/>

<!-- A function of the row, on row parts only -->
<BbTable :pt:row="({ item }) => item.overdue && 'bg-red-50'" />

Functions have their own section. The app-wide map and named scopes take the object prop's keys: see every instance in the app and an area's own look.

  • One part, at most one state. pt:icon:loading:disabled does not exist. To combine two states, write CSS on the modifier classes (.bb-button--loading.bb-base-button--disabled).
  • A string is a class list. The object adds style and attrs. attrs is v-bind on the part: data-*, inputmode or spellcheck on a text field's pt:input, a viewBox on an icon. The object is not a Vue :class object: { 'text-red-500': overdue } applies nothing and only warns in dev, so write a ternary instead.
  • A broadcast part lands on every node it names. pt:row reaches every data row, pt:icon both icons of a button. An id on a broadcast part duplicates.
  • A row's own state stays on that row. pt:option:selected reaches only the chosen options and pt:row:highlighted only the open row, and both follow the selection. A state of the whole control (loading, errors, open) reaches every node at once.
  • A slot replaces the node, and its pt with it. When you render your own markup, put the class on it. The icon slots named after a prop (#icon, #prepend:icon, #append:icon on BbButton and BbBadge) are the exception: they render inside the icon part, so pt:icon still lands.
  • Templates take either case, objects take camelCase. pt:item-icon and pt:box:has-value work in a template. In an object, a global map or a scope write itemIcon and 'box:hasValue': a kebab key there matches nothing.
  • A component's variant is a state. pt:root:destructive, pt:box:ghost, { 'item:destructive': '…' } apply on the parts the variant styles while that variant is on (per entry on a dropdown). A name your app registers in the build plugin (dropdownItemVariants: ['warning']) works the same way: pt:item:warning. A registered name that is already one of the component's states fails the build. Sizes, placements and label modes are not states.

A part word means the same thing on every component. box is a field's bordered box, and panel the visible surface of anything that floats (on BbTabs and BbAccordion, the content pane). A row is row on a table, option on a select, day on a date picker and swatch on a colour palette; elsewhere it is item. close dismisses a surface, clear empties a control. icon is always a content icon, never a chevron or a status symbol.

Tailwind autocomplete works inside pt:* values and :pt objects. The build plugin's vscodeSettings option teaches the Tailwind CSS IntelliSense extension those attributes when the dev server boots. Reload the editor window once after that first boot. With vscodeSettings: false, add the settings yourself: the package lists them in dist/ai/guides/installation-and-plugin-setup.md.

How the sources merge

A part starts from its base entry, then its variant's entry (root:destructive), then each active state's entry merges on top: a variant beats the base, and disabled, selected or loading still beat the variant. Inside one entry the sources merge in this order, and the last one wins a conflict:

  1. the global map, bitbossUi({ pt })
  2. the named scopes, outermost <BbPtScope> first
  3. the instance's class (on root), then its pt object prop
  4. the pt:<part> attribute

So a state entry from the global map still beats a base entry on the instance, while that state is on.

What you passWhat happens
classAdds to the component's classes and never removes a bb-* one. A conflicting utility from an earlier source is dropped: text-slate-400 then text-red-500 leaves only text-red-500
styleMerges property by property
A plain attributeWins, even over one the component manages, such as disabled or placeholder
A listenerRuns after the component's own handler and cannot cancel it

When two states are on at once, the later one in this list wins, on every component:

text
hasValue → value → placeholder → nearLimit → atLimit → empty → outside → today
→ inRange → activeLink → exactActiveLink → sorted → sortedDesc → selected → rangeStart → rangeEnd → checked
→ indeterminate → pressed → readonly → disabled → warnings → errors → active → expanded
→ open → fullscreen → focused → focusVisible → highlighted → loading

The more temporary state wins: a spinner replaces a warning icon, and an error shows red on a disabled field.

Conflicting utilities are settled by the bundled tailwind-merge v3. On Tailwind v3, point the passthroughDeduper plugin option at a module of your own that uses tailwind-merge@2, or set it to false. Your own @theme tokens dedupe by the property they set: the build plugin reads the @theme blocks in your CSS, so text-h2 (a font size) and text-ink (a colour) both stay, while two sizes still dedupe. In dev it follows your CSS as you edit it, a token or a file added or deleted. A token defined elsewhere (a plugin, a package) is not seen: configure it with extendTailwindMerge and point passthroughDeduper at that module.

The deduper settles your classes against each other, not against the library's stylesheet. Where a part's own rule sets a property, your utility wins only in the layered build. BbButton is the usual case: without cssLayers, pt:root="px-6 rounded-lg" does nothing. Use size, or set the button's own variables: :pt:root="{ style: { '--px': '20px', '--r': '9999px' } }".

Per-item functions

A state says which rows are on, and a function says what a row holds. On row parts, pt takes a function of the item and its states. Here cancelled and refunded orders fade, and the open one is tinted. Click a row to open it.

Order
Status
Total
#0417
Delivered
€1,298.00
#0418
Delivered
€338.80
#0421
Refunded
€899.00
#0426
Delivered
€477.00
#0430
Delivered
€2,196.00
#0435
Cancelled
€257.00

item is the row as you passed it in items, typed from them. On the dropdowns and on BbTabs it is the library's entry type instead, so a field of your own (danger) needs a cast. Every state is a boolean beside it. Return a class list, an object, or false to leave the row alone. The result merges after every state entry, which is why the function above skips the highlighted row.

The same function fits inline, :pt:row="({ item }) => item.overdue && 'bg-red-50'". pt:row and :pt:row are one prop, so a static class list beside a function goes in the object form. The demo keeps the function in :pt and the state in pt:row:highlighted, which are different keys. The state's tint brings its own hover: shade, because a background class replaces the one the library's hover changes.

A table row's text colour never reaches its cells, because each cell paints color: var(--bb-text) itself. Set the token on the row, [--bb-text:…], instead of a text-* class. A fill (bg-*) does show through.

Only row parts take a function:

ComponentParts
BbTablerow (for one cell, use the column's tdClass)
BbSelect, BbSelectPopoveroption, itemText, itemIcon, itemDescription; on BbSelect also badge and comma, one per selected value
BbDropdown, BbDropdownButtonitem, itemText, itemIcon, itemDescription
BbCheckboxGroup, BbRadioGroup, BbSwitchGroupitem, icon, label, itemDescription
BbTabsitem
BbTagbadge, comma (the item is the tag string)

On BbTable, row-class does the same from the item alone. Use a pt:row function when you need the row's states, as the demo does with highlighted.

A function goes on the base key only, never on row:selected, and never in the global map or a scope. It runs for every rendered row on every render, so keep it a cheap read of the item.

Parts inside a popover or sheet

A select's options live in the popover it opens, and on a phone in a bottom sheet. The select still owns the words: pt:option and pt:item-icon on BbSelect land wherever the rows render, in the popover or in the sheet. Open the select below: the chosen method's icon turns full ink, the primary colour here.

Express

Every floating surface splits the same way: BbPopover, BbDropdown, BbSelectPopover, and the date, time and colour pickers.

  • pt:panel is the surface you see: the bubble on desktop, the sheet on a phone. Background, padding, border and radius go here.
  • pt:root is an invisible positioning wrapper, with no node on a phone. Padding or a border on it draws a frame around the surface, and a click on that frame closes it.
  • pt:sheet reaches only the phone sheet and wins over panel there. Use it when a desktop w-64 or rounded-md on panel would reshape the sheet: pt:sheet="w-full rounded-t-(--bb-radius-surface)". Leave overflow-hidden off a panel that can become a sheet: the clip shows a gap under the sheet while it is dragged. Pin :adaptive="false" where you need the clip.

The inputs that open one (BbSelect, the date, time and colour inputs) keep pt:root on the field and forward pt:panel and pt:sheet to the surface.

To style what is inside the sheet on a phone only, such as its header, footer or close button, put pt in offCanvasProps. It takes BbOffCanvas's own parts and never touches the desktop surface:

vue
<!-- A muted header on both, with more room above it on a phone. -->
<BbDialog
    id="record-payment"
    title="Record payment"
    pt:header="bg-(--bb-muted) pt-1"
    :off-canvas-props="{ 'pt:header': 'pt-10' }"
/>

On the date, time and colour pickers this is the only way into the sheet's header, body and close button. Their sheet hides its close button, so set hideClose: false in offCanvasProps before styling pt:close.

A dialog's backdrop is the ::backdrop pseudo-element, which pt cannot reach. Style .bb-dialog::backdrop in CSS.

Style every instance in the app

To style every instance in the app, key the plugin's pt option by component, then by part. Keep the map in its own file and wrap it in definePt, which checks component names, keys and values where you write them:

bitboss-ui.pt.ts
// In a Nuxt app, import definePt from 'bitboss-ui/nuxt'.
import { definePt } from 'bitboss-ui/vite';

export const pt = definePt({
    BbTextInput: { label: 'text-xs font-medium' },
    BbTable: {
        row: 'h-9',
        'row:highlighted': 'bg-(--bb-info)/10 hover:bg-(--bb-info)/15',
    },
});
vite.config.ts
import { defineConfig } from 'vite';
import { bitbossUi } from 'bitboss-ui/vite';
import { pt } from './bitboss-ui.pt';

export default defineConfig({ plugins: [bitbossUi({ pt })] });

In Nuxt, pass the same map as bitboss: { pt } in nuxt.config.ts. Import only from bitboss-ui/vite or bitboss-ui/nuxt in that file, since the config loads in Node.

  • It is data only. The map ships as JSON, so a listener or a per-item function fails the build, and is already a type error inside definePt.
  • It is checked at build time. A misspelt component (BbButon) or key (icno) fails the build, and the message lists the right words.
  • It is build-time only. There is no runtime pt option, and setConfig({ pt }) does not exist.

An instance still beats the map within one key, as in the merge order. Confirms and toasts have no instance to style: <BbHost /> takes no pt, so their BbConfirm and BbToast entries in this map, or in a scope around the host, are the only way in.

BbTabsList and BbTabsPanels, the pieces of a split tabs layout, take their own entries too. A piece's entry lands after the BbTabs one beside it, so a theme written once for BbTabs covers both layouts.

Give an area its own look

Some apps have areas with different looks: a backoffice beside a member area, or one area per tenant. Give each area a map in ptScopes, the same shape as pt, and wrap the area in <BbPtScope name>:

bitboss-ui.pt.ts
import { definePt } from 'bitboss-ui/vite';

export const ptScopes = {
    webapp: definePt({
        BbTextInput: { label: 'text-(--bb-text-muted)' },
        BbButton: { root: 'rounded-full' },
    }),
    backoffice: definePt({ BbButton: { root: 'rounded-md' } }),
};

// vite.config.ts: bitbossUi({ pt, ptScopes })
// nuxt.config.ts: bitboss: { pt, ptScopes }
WebappLayout.vue
<template>
    <BbPtScope name="webapp">
        <slot />
        <BbHost />
    </BbPtScope>
</template>

<script setup lang="ts">
import { BbHost, BbPtScope } from 'bitboss-ui';
</script>
  • It renders no element. name is reactive: change it and the subtree restyles.
  • Scopes add up. An inner scope merges after the outer one and never hides it. An instance's own pt still beats both.
  • The name is typed. The plugin writes the scope names into bitboss-ui.d.ts, so name="webap" fails vue-tsc once the dev server or a build has run. Commit that file. With no ptScopes configured, name accepts nothing, and the type error says so.
  • It follows the component tree, not the DOM. A dialog, popover or picker opened inside the scope uses it wherever it paints. A directive such as v-bb-tooltip reads the scope around the component whose template it is written in. Put the scope in a layout, not in the same template.
  • Toasts and confirms paint where the host is mounted. Mount one <BbHost /> (BbToast, BbConfirm) inside each area's layout, within its scope, and none at the app root. This holds only when every page renders inside exactly one of those layouts: a page outside them has no host, and a confirm there resolves false after a second. A confirm or toast fired while you switch areas renders in the next host.
  • A scope restyles parts, not tokens. An area's colours and radius go on a bb-theme island. Most areas want both.

Typing pt in script

The Pt* types are not exported. Use the component's own instead: <Component>Props['pt'] carries its parts, its states and its row type.

A preset or a per-item function takes satisfies, which types item and the states and still catches a misspelt field:

ts
import type { BbTableProps } from 'bitboss-ui';

type Invoice = { id: number; status: 'paid' | 'overdue' };

// Any table: leave the row type out.
// `BbTableProps<any>` turns the checks off.
export const compactTable = {
    root: 'text-sm',
    row: 'h-8',
} satisfies BbTableProps['pt'];

export const invoiceTable = {
    ...compactTable,
    row: ({ item }) => item.status === 'overdue' && 'bg-red-50',
} satisfies BbTableProps<Invoice>['pt'];

A wrapper that forwards pt types the prop with the wrapped component's pt, so callers get its parts and its row type:

vue
<template>
    <BbSelect
        v-model="model"
        item-text="name"
        item-value="id"
        :items="items"
        label="Member"
        :pt="pt"
    />
</template>

<script setup lang="ts">
import { BbSelect, type BbSelectProps } from 'bitboss-ui';

type Member = { id: number; name: string };

defineProps<{ items: Member[]; pt?: BbSelectProps<Member>['pt'] }>();
const model = defineModel<number | null>({ default: null });
</script>

The other wrapper rules are in Options and items. A component that only presets props, pt included, needs no wrapper: see Preset components.

A global map split across files wraps each fragment in definePt where it is written, then spreads them. Fragments merge per component, so a component in two fragments keeps only the later one: give each component one home.

ts
// forms.pt.ts
import { definePt } from 'bitboss-ui/vite';

export const formsPt = definePt({
    BbTextInput: { label: 'text-xs font-medium' },
});

// tables.pt.ts
import { definePt } from 'bitboss-ui/vite';

export const tablesPt = definePt({ BbTable: { row: 'h-8' } });

// vite.config.ts
bitbossUi({ pt: { ...formsPt, ...tablesPt } });

Migrating a :deep() rule

A :deep() rule on a library class keeps working. Part and state classes, such as .bb-table__row and .bb-table__row--highlighted, change only with a migration entry. Every other .bb-* class is internal and can change in any release. A pt part is also typed and linted, which no selector is. To move a rule onto a part:

  1. Find the part. .bb-field-input__box is box, .bb-table__row is row, and .bb-listbox__option--selected is option:selected. If there is no part, keep the rule: it targets structure the library does not promise.
  2. Ask whether several places share it. If they do, it is a variant or a token.
  3. Give the part the utilities, or a class of your own that keeps the rule.
  4. Replace a modifier selector with a state.
vue
<!-- Before -->
<BbTable class="orders" :columns="columns" :items="orders" />

<style scoped>
.orders :deep(.bb-table__row--highlighted) {
    background: color-mix(in oklab, var(--bb-info) 10%, transparent);
}
</style>

<!-- After -->
<BbTable
    :columns="columns"
    :items="orders"
    pt:row:highlighted="bg-(--bb-info)/10 hover:bg-(--bb-info)/15"
/>

Scoped styles do not reach a forwarded part: pt:row="order-row" with .order-row in <style scoped> matches nothing. Write :deep(.order-row) or use an unscoped block. The class is yours, so no library rename can break it.

npx bitboss-ui check reads your stylesheets and flags a selector on a .bb-* class the library no longer renders, with the part to use instead.