A theme is a handful of --bb-* values; everything else derives from them. Of
the 112 custom properties the library ships, 41 are knobs you set and the rest
are formulas. Start in the theme builder, export the light
and dark blocks, then use this page to decide which values to pin.
Agents get the same set as data: bitboss-ui/dist/ai/design-tokens.json, with a
DESIGN.md-shaped twin in design-tokens.manifest.yaml. Grep for --bb-*; there
is no --color-* or --radius-* scale to find.
Two tiers, two prefixes
The prefix tells you which tier a token belongs to, and the tiers do not mix.
--bb-* are theme tokens. Global, defined in the library's variables.css,
themable. This is the public API surface, and it is what fills the devtools
Styles panel, so it stays readable in one screen.
--*, with no prefix, are component tokens. Each component declares its own
on its root class, so set them there, never on :root
(Overriding a component token). Custom properties inherit,
and declaring them on the root class keeps a --gap set by an outer component
from leaking into an inner one.
The brand knobs
The colour knobs a theme sets. Shape, scale, motion and overlay knobs are in
the next sections, and design-tokens.json lists all 41 with their
values.
The brand colour and what sits on it:
--bb-primary: the brand accent. Lightrgb(0 0 0), darkrgb(255 255 255).--bb-primary-fg: text and icons on primary, auto-contrasted. Lightrgb(255 255 255), darkrgb(0 0 0).--bb-primary-border: the outline of a primary plate. It follows primary; pin it when a light fill needs an edge.--bb-link-fg: the text of link-style buttons. It follows primary; pin it when the primary does not read as text.
Status colours:
--bb-danger: errors and destructive intent, and message text too. Lightrgb(217 59 48), darkrgb(251 113 133).--bb-danger-fg: text and icons on a danger fill. Lightrgb(255 255 255), darkrgb(0 0 0).--bb-success: the success fill of toasts and indicator bubbles.rgb(21 128 61)in both schemes.--bb-success-fg: text and icons on success.rgb(255 255 255)in both schemes.--bb-info: the info fill of toasts and indicator bubbles.rgb(37 99 235)in both schemes.--bb-info-fg: text and icons on info.rgb(255 255 255)in both schemes.--bb-warn: warning chrome and icons. Lightrgb(240 131 88), darkrgb(245 158 11).--bb-text-warn: warning message text. Lightrgb(180 83 9), darkrgb(245 158 11).--bb-warn-fg: text on a warning fill, which is the indicator bubble.rgb(255 255 255)in both schemes.
Surfaces, text and lines:
--bb-panel: raised surfaces such as cards, inputs, popovers and dialogs. Lightrgb(255 255 255), darkrgb(49 54 56).--bb-text: the foreground. Lightrgb(0 0 0), darkrgb(255 255 255).--bb-border: hairlines; pair it with--bb-border-w. Lightrgb(212 212 216), darkrgb(82 82 91).--bb-ring-opacity: the strength of the focus ring. Light15%, dark40%.--bb-secondary-tint: how much text tints--bb-secondary. Light4%, dark6%, so a secondary field sits as far from an outline one in dark as it does in light.
--bb-primary-fg auto-contrasts. Where the browser supports CSS
contrast-color() it is derived to black or white, whichever reads on
--bb-primary. Set only the accent and you still get legible text on primary
fills. The values above are the fallback for browsers without it, and setting
the token explicitly always wins over both.
Warning is two tokens, not one. --bb-warn paints borders, focus rings and
the warning icon. That is decorative reinforcement of a state the icon's shape
and the message already carry. --bb-text-warn paints the messages in the live
region, which is real text, so it keeps a 4.5:1 budget. One token dark enough for
the copy browns the chrome, which is why there are two. In dark mode amber-500
clears both budgets, so the split is a light-theme distinction only.
--bb-success and --bb-info are fills, not text. They exist because the
toast and the indicator ship those variants. On the dark panel both fall below
4.5:1, so do not paint running text with them; mix toward --bb-text first.
--bb-danger does both jobs, since it is also the error message colour. A
danger you choose must still clear 4.5:1 as 14px text on the panel.
The -fg values are pinned, not auto-contrasted. Each is a literal chosen to
clear 4.5:1 on the shipped fill, so repin the foreground whenever you repin the
fill under it. One is below budget on purpose: white on the light salmon of
--bb-warn-fg is 2.59:1, and 2.15:1 on the dark amber. It only ever carries a
short number.
Derived colours
These follow the knobs on their own. A theme normally never touches them, though
any of them can be pinned, for example to hand-tune the greys. Every mix is
in oklab.
--bb-muted:--bb-text6% into--bb-panel, for static subtle fills and disabled surfaces.--bb-track:--bb-text15% into--bb-panel, for recessed control tracks such as switch and slider rails.--bb-hover:--bb-text4% into--bb-panel, the hover fill for affordances such as buttons and nav links.--bb-surface-hover:--bb-text2% into--bb-panel, the hover fill for passive surfaces such as rows and day cells.--bb-pressed:--bb-text8% into--bb-panel, the neutral pressed fill.--bb-text-muted:--bb-textat 60% opacity, for secondary text, icons and placeholders.--bb-text-faint:--bb-textat 45% opacity, for disabled foregrounds.--bb-ring:--bb-primaryat--bb-ring-opacityopacity, the focus ring of primary and link variants.--bb-ring-danger: the same with--bb-danger, the focus ring in error states.--bb-ring-warn: the same with--bb-warn, the focus ring in the warning state.--bb-ring-neutral: the same with--bb-text, the focus ring of neutral variants: grey, never primary.--bb-field-border:--bb-border, the resting border of every field box. Set it to restyle all fields and nothing else.--bb-secondaryand its-hover,-pressed,-border,-fgand-ring: one value per state for the button plates. The same family exists foroutline,ghost,linkanddestructive, plus--bb-primary-hoverand-pressed. Pin one to retune that variant everywhere.
Three knobs sit in between: --bb-primary-fg, --bb-primary-border and
--bb-link-fg follow another knob until you pin them, and a pin has to be
repeated in every scheme block you write, .dark included. The MCP server's
list_tokens reports each token's kind (knob, derived or
knob-tracking), so a tool can tell which names are yours to set.
Shape, scale and motion
The default of each knob is in brackets.
--bb-radius(8px): the knob, for buttons, inputs and tab strips.--bb-radius-surface(radius × 1.8): dialogs and off-canvas sheets.--bb-radius-sm(radius × 0.6, 4px floor): nested bits such as table controls and the toast close. Never above--bb-radius.--bb-border-w(1px): hairlines on plates and dividers. Give it a unit:0px, not0.--bb-ring-size(2px): the focus ring thickness.--bb-elev(flat): the resting shadow on controls, a fullbox-shadowvalue.--bb-shadow(soft, two layers): overlays such as dialogs, popovers, menus and toasts.--bb-surface-filter(none): frost on raised plates, a fullbackdrop-filtervalue.--bb-control-h(32px): the master control height; buttons and tabs derive from it.--bb-fs(14px): the master font size; component sizes derive from it.--bb-panel-p(16px): the content padding of large surfaces.
Radius is tiered by role, not by size, and both extra tiers derive from the
one knob while staying individually pinnable. Nest concentrically with
outer radius − padding. Derive per-size ramps inside a component from
--bb-radius with multipliers, never with literals.
A glass theme sets --bb-surface-filter: blur(22px) saturate(1.8) on :root
and gives --bb-panel some transparency, or there is nothing to see through.
Overlays have knobs of their own:
--bb-overlay(rgb(0 0 0 / 0.1), dark0.5): the backdrop dim, colour and opacity in one.--bb-overlay-above(--bb-overlay): the dim under a dialog or sheet opened over another;transparentkeeps one dim under the whole stack. A dialog that replaces a member of its ownstackadds none.--bb-overlay-blur(2px): the backdrop blur, on the bottom-most backdrop only.--bb-z-overlay(10): the overlay z-index.--bb-arrow(1): popover arrows on (1) or off (0).
The input metrics (--bb-input-px, --bb-input-py, --bb-input-icon and the
rest) are in design-tokens.json. One worth knowing: --bb-input-fs-mobile
keeps field text at 16px or more on phones so iOS does not zoom on focus.
There is no font token. Components inherit the page's font-family, so set
one on body; an app that sets none renders in the browser's serif. The
optional reset (resetCss: true) sets a system-ui stack, and your own
font-family still wins.
Field text has three families of its own, all derived from --bb-fs: the label,
the description under it, and the message line under the control. The hint,
errors and warnings share that last family because they take turns in the same
place.
--bb-label-fs(--bb-fs): the label above a field;--bb-label-weightis500.--bb-description-fs(--bb-fs): field and group-row descriptions.--bb-label-fs-sm(--bb-fs− 2px): inside and floating labels.--bb-description-fs-sm(--bb-fs− 2px): the description under an inside label.--bb-message-fs(--bb-fs): the hint, errors and warnings.
Each family also has a -weight and a -lh token. Set a size and its line
height follows; on an island, put bb-theme on the root so the -lh formulas
re-derive there.
Library default
Smaller description and message
<template>
<div class="grid gap-6 md:grid-cols-2">
<div
v-for="column in columns"
:key="column.id"
class="grid content-start gap-3"
:class="column.island ? 'bb-theme' : undefined"
:style="column.island ? quietText : undefined"
>
<p class="m-0 text-xs font-medium text-(--bb-text-muted)">{{ column.caption }}</p>
<!-- The label keeps its own family: only the lines around it move. -->
<BbTextInput
:id="`field-text-${column.id}`"
v-model="slug"
description="Your team signs in at this address."
:errors="['This address is already taken.']"
label="Workspace URL"
:name="`workspace_url_${column.id}`"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbTextInput } from 'bitboss-ui';
const slug = ref<string | null>('northwind');
const columns = [
{ id: 'default', caption: 'Library default', island: false },
{ id: 'quiet', caption: 'Smaller description and message', island: true },
];
// `.bb-theme` re-runs the derived formulas on this element, so each
// `-lh` line height follows its new `-fs` instead of staying at the page's.
const quietText = {
'--bb-description-fs': 'calc(var(--bb-fs) - 2px)',
'--bb-message-fs': 'calc(var(--bb-fs) - 2px)',
'--bb-message-weight': '500',
};
</script>
Motion is a closed ladder. --bb-transition-fast (150ms) is for
micro-interactions, --bb-transition-medium (250ms) for overlays and most moves,
and --bb-transition-slow (350ms) for large consumer surfaces. --bb-ease is
the only easing curve. Under prefers-reduced-motion the components drop every
movement (slides, zooms, scale-ins) and keep the fades.
The emphasis ladder
All tinting goes through one ladder of color-mix() percentages. No other
percentage is allowed in a mix with a semantic colour.
| Stop | Meaning | Example |
|---|---|---|
8% | Resting tint fill | Secondary button background, tab strip |
15% | Hover tint fill | Secondary and destructive button hover |
25% | Pressed or selected tint, tinted borders | aria-pressed background, danger border |
45% | Disabled foreground | Disabled button text |
60% | Muted text, which is --bb-text-muted | Secondary copy, icons |
75% | Strong but secondary foreground | Toast icon, alert secondary copy |
Two composition modes, chosen by context:
/* Opaque fill — the element owns its background. */
background: color-mix(in oklab, var(--accent) 8%, var(--bb-panel));
/* Translucent fill — it sits on an unknown background. */
background: color-mix(in oklab, var(--accent) 8%, transparent);
Solid, non-tinted surfaces darken instead: 90% toward black on hover, 80% on
active.
Every interactive component then follows one four-state progression:
- Neutral chrome: transparent at rest (
8%if filled),var(--bb-hover)on hover,var(--bb-pressed)when pressed. - Tinted variants:
8%at rest,15%on hover,25%when pressed. - Solid variants:
100%at rest,90%+ black on hover,80%+ black when pressed. - Disabled, in all three: a
var(--bb-muted)fill with the text at45%.
Focus adds box-shadow: 0 0 0 var(--bb-ring-size) var(--bb-ring) on top of the
hover values.
Grays stay gray; primary tint means selected. The tinted progression applies only to states that mean something, such as danger variants and brand-selected states. It never applies to plain interaction chrome. That single rule is what makes a yellow theme produce grey hovers and yellow selections, instead of a yellow-washed UI.
Dark mode and the page canvas
One name per token. :root and .light declare the light values, and .dark
re-declares only the knobs that change. The derived tokens follow on their
own, because their formulas are declared again at every scheme boundary.
To pin a dark value, write the library's own selector pair. The dark block is
:root .dark, :root.dark, and with the library unlayered (the default) a bare
.dark { … } in your sheet loses to it, however late it loads:
:root .dark,
:root.dark {
--bb-primary: rgb(45 212 191);
}
With cssLayers set, your unlayered sheet
outranks the layered library and a bare .dark works too. Light pins on :root
work either way.
Each scheme block also sets the UA color-scheme, and that is load-bearing.
color-scheme governs the default canvas fill of any element with no
authored background, so it always follows the class rather than
prefers-color-scheme. Otherwise a .dark document under a light OS preference
would flip --bb-text to white while the canvas underneath stayed white, making
outline and ghost buttons invisible.
You need a value only when you want a deliberately tinted, recessed canvas so white cards visibly float. That is a project decision, so mint it in project CSS. Derive it and it flips on its own:
:root {
--app-canvas: color-mix(in oklab, var(--bb-text) 3%, var(--bb-panel));
}
body {
background: var(--app-canvas);
}
One CSS mechanic to respect: a custom property resolves its var() references
where it is declared, not where it is consumed. Override a knob on an arbitrary
scope, .brand { --bb-primary: … }, and the tokens that mix from it do not
re-derive. Add the bb-theme class to that scope: it re-runs the formulas
against the knobs set there and keeps the page's scheme.
Writing a theme shows one live. Root-level theming is unaffected.
Writing a theme
A theme is two small blocks of the same ten or so declarations, one per
scheme: :root for light and shared values, :root .dark, :root.dark for dark.
Change --bb-primary, --bb-radius, --bb-control-h, --bb-panel and
--bb-danger and you have overhauled the entire library with no other edits. A
theme lives in your own CSS, not in the plugin config (the plugin has no theme
option), so changing it needs no rebuild.
Ten knobs are registered with @property. Eight are colours with
syntax: '<color>': primary, primary-fg, danger, warn, text-warn, panel, text
and border. Two are percentages with syntax: '<percentage>': --bb-ring-opacity
and --bb-secondary-tint.
Registration also makes the knobs animatable. Add .theme-animated to the
element carrying the scheme class and theme changes crossfade over
--bb-transition-medium, with every derived mix re-deriving per frame
(<html class="dark theme-animated">). The whole subtree repaints while it
runs, a deliberate cost, and that is why it is opt-in.
One area can carry its own theme. Put bb-theme on its root and set only the
knobs that change. Pick a brand and a radius below: the card retints, hover and
focus follow, and the page around it stays as it was. Pin --bb-primary-fg with
the new primary, since the island keeps the page's scheme.
Workspace settings
Pro plan<template>
<div class="grid gap-6 md:grid-cols-[minmax(0,14rem)_minmax(0,1fr)]">
<div class="grid content-start gap-5">
<BbRadioGroup
id="island-brand"
v-model="brandName"
:items="brands"
legend="Brand primary"
list-layout="vertical"
name="island_brand"
/>
<BbSlider id="island-radius" v-model="radius" :label="`Radius: ${radius}px`" :max="16" :min="0" name="island_radius" :step="2" />
</div>
<!--
`.bb-theme` re-runs the derived formulas against the knobs set on this
element, so hover, pressed and focus ring follow the new primary. Without
it they would stay computed from the page's own `--bb-primary`.
-->
<div class="bb-theme grid content-start gap-4 rounded-[var(--bb-radius-surface)] border border-[var(--bb-border)] bg-[var(--bb-panel)] p-[var(--bb-panel-p)]" :style="islandStyle">
<div class="flex items-center justify-between gap-2">
<p class="m-0 font-medium">Workspace settings</p>
<BbBadge variant="secondary">Pro plan</BbBadge>
</div>
<BbTextInput id="island-name" v-model="workspace" label="Workspace name" name="workspace_name" />
<BbSwitch id="island-digest" v-model="digest" label="Send a weekly digest" name="weekly_digest" />
<div class="flex flex-wrap gap-2">
<BbButton variant="primary">Save changes</BbButton>
<BbButton variant="outline">Cancel</BbButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbBadge, BbButton, BbRadioGroup, BbSlider, BbSwitch, BbTextInput } from 'bitboss-ui';
// `fg` is pinned with each primary: the island keeps the page's scheme, so the
// text on a new primary is the one knob that does not follow on its own.
const brands = [
{ label: 'Library default', value: 'default', primary: null, fg: null },
{ label: 'Ocean', value: 'ocean', primary: '#2563eb', fg: '#ffffff' },
{ label: 'Forest', value: 'forest', primary: '#047857', fg: '#ffffff' },
{ label: 'Plum', value: 'plum', primary: '#7c3aed', fg: '#ffffff' },
];
const brandName = ref('ocean');
const radius = ref(8);
const workspace = ref<string | null>('Northwind Traders');
const digest = ref(true);
const islandStyle = computed(() => {
const brand = brands.find((entry) => entry.value === brandName.value);
return {
'--bb-radius': `${radius.value}px`,
...(brand?.primary ? { '--bb-primary': brand.primary, '--bb-primary-fg': brand.fg } : {}),
};
});
</script>
An area that also restyles parts, such as rounder buttons or smaller labels, pairs the island with a named passthrough scope.
Overriding a component token
Component tokens are real affordances. Set one on the element that declares it:
.settings .bb-tabs-list { --list-h: 40px } retunes the tabs in one scope (an
inline style on <BbTabs> does nothing, because the inner list redeclares
--list-h). Three rules, in the order they get broken.
Override only what you render. Several components expose a per-size lookup table plus an active token that the size class re-points. Setting the whole family because you want one value silently opts you out of the size scale for variants you may start using later.
Override the per-size entry, not the active token. The size class re-resolves the active token, and it is more specific than the root class:
.bb-button {
--icon-size: var(--icon-size-md);
} /* 0,1,0 */
.bb-button--sm {
--icon-size: var(--icon-size-sm);
} /* 0,2,0 — wins */
So .bb-button { --icon-size: 12px } appears to work on default buttons and does
nothing on every size="sm" one. Set --icon-size-sm instead: the per-size
entry has no competing declaration, so it always wins.
Scope it to the component, never :root. Short names are deliberately
reused. --icon-size is read by BbButton, BbBadge and the close icon, and
--gap, --px and --h are everywhere. Hoisting one to :root retunes every
component that shares the name, which is exactly what the sealing rule exists to
prevent.
In a Tailwind v4 app, set cssLayers first, as in
Installation. Without it the library's CSS
is unlayered. It then beats Tailwind utilities, and any override you write
inside an @layer block, on every property it sets.
Reading a token outside CSS
Charts, <canvas> and WebGL do not go through the cascade. They read a token's
computed value as a string and parse it themselves, and 21 shipped --bb-*
tokens are color-mix(in oklab, …) values: --bb-text-muted,
--bb-text-faint, --bb-muted, --bb-hover, the ring families, and more.
Neither obvious read path returns something a colour parser accepts.
getPropertyValue hands back the color-mix(…) expression itself: the var()s
are filled in, the mix is not. Read a resolved color instead and
you get oklab(0.599996 …), a syntax most canvas chart libraries never learned.
Resolve it through the only thing that understands every CSS colour: an offscreen canvas.
function resolveCssColor(value: string): string {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 1;
const ctx = canvas.getContext('2d')!;
ctx.fillStyle = '#000'; // baseline: a silent no-op stays black
ctx.fillStyle = value; // the browser parses and resolves here
ctx.fillRect(0, 0, 1, 1);
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
return `rgba(${r}, ${g}, ${b}, ${(a / 255).toFixed(3)})`;
}
const muted = getComputedStyle(el)
.getPropertyValue('--bb-text-muted')
.trim();
resolveCssColor(muted);
Do it for every token a canvas consumer touches, including the plain-looking
ones. Nothing marks a token as derived from the outside, and a dashboard reaches
for --bb-text-muted and --bb-text-faint first: axis labels, legends, muted
marks.