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:
<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.
<template>
<form class="grid max-w-md gap-4" @submit.prevent="send">
<!--
One part, three entries. `pt:icon` is the resting look; each state
entry merges on top of it while that state is on. With a value AND
an error, `errors` wins: it comes later in the state order.
-->
<BbTextInput
id="pt-invite-email"
v-model="email"
autocomplete="email"
:errors="touched ? error : undefined"
label="Invite a teammate"
name="invite_email"
placeholder="[email protected]"
prepend:icon="lucide:mail"
pt:icon="text-(--bb-text-faint)"
pt:icon:errors="text-(--bb-danger)"
pt:icon:has-value="text-(--bb-primary)"
:readonly="sending"
type="email"
@blur="touched = true"
/>
<div class="flex flex-wrap items-center gap-3">
<!-- The same grammar on the button: its label dims while sending. -->
<BbButton
:disabled="!email || !!error"
:loading="sending"
prepend:icon="lucide:send"
pt:text:loading="opacity-70"
type="submit"
variant="primary"
>
Send invite
</BbButton>
<p class="m-0 text-sm text-(--bb-text-muted)" role="status">
{{ status }}
</p>
</div>
</form>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbButton, BbTextInput } from 'bitboss-ui';
import { delay } from '~/demo-data';
const email = ref<string | null>('[email protected]');
const touched = ref(false);
const sending = ref(false);
const status = ref('');
const error = computed(() =>
email.value && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email.value)
? 'Enter a full email address.'
: undefined
);
async function send() {
sending.value = true;
status.value = '';
await delay(null, 1200);
sending.value = false;
status.value = `Invite sent to ${email.value}.`;
email.value = null;
touched.value = false;
}
</script>
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:
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
(
ghostfor no field chrome,nonefor a blank base), then useptfor what is left. A rebuilt ghost loses the specificity fight the variant exists to win:<!-- 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:<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-600hides the error frame, and the ESLint rulebitboss-ui/no-box-border-colorflags it. The state borders are classes:<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:<BbRating pt:root="[--accent:var(--color-amber-500)]" /> - A component whose only part is
root(BbAsterisk,BbIcon,BbSmoothHeight,BbSpinner) takes plainclassandstyle. - Behaviour goes through props and events. A listener in
ptruns after the component's own and cannot cancel it. Icons and spinners ignore the pointer, so anonClickonpt:iconnever 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:
<!-- 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:disableddoes 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
styleandattrs.attrsisv-bindon the part:data-*,inputmodeorspellcheckon a text field'spt:input, aviewBoxon an icon. The object is not a Vue:classobject:{ '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:rowreaches every data row,pt:iconboth icons of a button. Anidon a broadcast part duplicates. - A row's own state stays on that row.
pt:option:selectedreaches only the chosen options andpt:row:highlightedonly 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
ptwith it. When you render your own markup, put the class on it. The icon slots named after a prop (#icon,#prepend:icon,#append:icononBbButtonandBbBadge) are the exception: they render inside theiconpart, sopt:iconstill lands. - Templates take either case, objects take camelCase.
pt:item-iconandpt:box:has-valuework in a template. In an object, a global map or a scope writeitemIconand'box:hasValue': a kebab key there matches nothing. - A component's
variantis 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:
- the global map,
bitbossUi({ pt }) - the named scopes, outermost
<BbPtScope>first - the instance's
class(onroot), then itsptobject prop - 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 pass | What happens |
|---|---|
class | Adds 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 |
style | Merges property by property |
| A plain attribute | Wins, even over one the component manages, such as disabled or placeholder |
| A listener | Runs 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:
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.
<template>
<!--
`pt:row:highlighted` is a state: the open row. It gets its own
`hover:` shade, because the library's hover works through a
variable that a background class replaces.
-->
<BbTable
v-model:highlighted="openOrder"
caption="Recent orders"
:columns="columns"
item-value="id"
:items="recentOrders"
:pt="orderRows"
pt:row:highlighted="bg-(--bb-info)/10 hover:bg-(--bb-info)/15"
>
<template #status="{ item, content }">
<BbBadge
size="sm"
:variant="closed.includes(item.status) ? 'outline' : 'secondary'"
>
{{ content }}
</BbBadge>
</template>
</BbTable>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbBadge, BbTable } from 'bitboss-ui';
import type { BbTableColumn, BbTableProps } from 'bitboss-ui';
import type { Order, OrderStatus } from '~/demo-data';
import { orderStatusLabels, orders } from '~/demo-data';
const euro = new Intl.NumberFormat('en-IE', {
style: 'currency',
currency: 'EUR',
});
const columns: BbTableColumn<Order>[] = [
{
key: 'reference',
label: 'Order',
rowHeader: true,
formatter: (value: string) => value.replace('ORD-2026-', '#'),
},
{
key: 'status',
label: 'Status',
formatter: (value: OrderStatus) => orderStatusLabels[value],
},
{ key: 'total', label: 'Total', align: 'right', formatter: euro.format },
];
const closed: OrderStatus[] = ['cancelled', 'refunded'];
// This `row` function reads the order itself. It merges after every
// state entry, so it skips the open row, or the fade would dim the
// highlight. `satisfies` types `item` as an Order. (Opacity, not a
// text colour: each cell sets its own colour.)
const orderRows = {
row: ({ item, highlighted }) =>
closed.includes(item.status) && !highlighted && 'opacity-60',
} satisfies BbTableProps<Order>['pt'];
const openOrder = ref<number | null>(9002);
const recentOrders = orders.slice(0, 6);
</script>
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:
| Component | Parts |
|---|---|
BbTable | row (for one cell, use the column's tdClass) |
BbSelect, BbSelectPopover | option, itemText, itemIcon, itemDescription; on BbSelect also badge and comma, one per selected value |
BbDropdown, BbDropdownButton | item, itemText, itemIcon, itemDescription |
BbCheckboxGroup, BbRadioGroup, BbSwitchGroup | item, icon, label, itemDescription |
BbTabs | item |
BbTag | badge, 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.
<template>
<!--
`option` and `itemIcon` are the select's own words, but the nodes
live in the popover it opens, and in the sheet it becomes on a
phone. `selected` resolves per row: only the chosen one.
-->
<BbSelect
id="pt-shipping"
v-model="method"
class="max-w-sm"
:item-props="{ description: 'eta', 'prepend:icon': 'icon' }"
item-text="name"
item-value="id"
:items="methods"
label="Shipping method"
name="shipping_method"
:prepend:icon="chosen?.icon"
pt:item-icon="text-(--bb-text-muted)"
pt:item-icon:selected="text-(--bb-primary)"
pt:option:selected="font-medium"
/>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbSelect } from 'bitboss-ui';
const methods = [
{
id: 'standard',
name: 'Standard',
eta: 'Free, 3 to 5 working days',
icon: 'lucide:truck',
},
{
id: 'express',
name: 'Express',
eta: '€9.90, next working day',
icon: 'lucide:rocket',
},
{
id: 'pickup',
name: 'Pick up in store',
eta: 'Free, ready in 2 hours',
icon: 'lucide:store',
},
];
const method = ref<string | null>('express');
const chosen = computed(() => methods.find((m) => m.id === method.value));
</script>
Every floating surface splits the same way: BbPopover, BbDropdown,
BbSelectPopover, and the date, time and colour pickers.
pt:panelis the surface you see: the bubble on desktop, the sheet on a phone. Background, padding, border and radius go here.pt:rootis 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:sheetreaches only the phone sheet and wins overpanelthere. Use it when a desktopw-64orrounded-mdonpanelwould reshape the sheet:pt:sheet="w-full rounded-t-(--bb-radius-surface)". Leaveoverflow-hiddenoff 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:
<!-- 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:
// 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',
},
});
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
ptoption, andsetConfig({ 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>:
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 }
<template>
<BbPtScope name="webapp">
<slot />
<BbHost />
</BbPtScope>
</template>
<script setup lang="ts">
import { BbHost, BbPtScope } from 'bitboss-ui';
</script>
- It renders no element.
nameis 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
ptstill beats both. - The name is typed. The plugin writes the scope names into
bitboss-ui.d.ts, soname="webap"failsvue-tsconce the dev server or a build has run. Commit that file. With noptScopesconfigured,nameaccepts 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-tooltipreads 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 resolvesfalseafter 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-themeisland. 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:
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:
<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.
// 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:
- Find the part.
.bb-field-input__boxisbox,.bb-table__rowisrow, and.bb-listbox__option--selectedisoption:selected. If there is no part, keep the rule: it targets structure the library does not promise. - Ask whether several places share it. If they do, it is a variant or a token.
- Give the part the utilities, or a class of your own that keeps the rule.
- Replace a modifier selector with a state.
<!-- 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.