The theme layer is 91 custom properties, down from around 140 in v2. That number is the point. A token earns its place only when overriding it is a decision a real consumer would make, and everything else is a formula.
Turn one knob and the whole library retunes coherently. That is the claim this page keeps returning to. Start in the theme builder, export the generated light and dark blocks, then use this page to decide which values belong in project CSS and which should stay derived.
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. It is also what crowds your devtools
console, so it stays readable in one screen.
--*, with no prefix, are component tokens. Declare them on the component's
own root class, in the component's own CSS file, never in :root. A
--bb-tab-card-radius-md must never exist.
Rule two is what makes the split hold: a component token is declared in the
same file that consumes it. Custom properties inherit, so a --gap set by an
outer component would otherwise leak into an inner one. Declaring it on the root
class seals the component.
The brand knobs
The complete set a theme sets. If a value is not here, it is not globally themable, and that is a feature rather than a gap.
| Token | Light | Dark | Meaning |
|---|---|---|---|
--bb-primary | rgb(0 0 0) | white | The brand accent |
--bb-primary-fg | white | black | Text and icons on primary, auto-contrasted |
--bb-danger | rgb(217 59 48) | rose-400 | Errors, destructive intent |
--bb-warn | rgb(240 131 88) | amber-500 | Warning chrome and icons |
--bb-text-warn | rgb(180 83 9) | amber-500 | Warning message text |
--bb-panel | rgb(255 255 255) | #313638 | Raised surfaces: cards, inputs, popovers, dialogs |
--bb-text | rgb(0 0 0) | white | Foreground |
--bb-border | zinc-300 | zinc-600 | Hairlines; pair with --bb-border-w |
--bb-ring-opacity | 15% | 40% | Ring strength |
--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 table's values 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.
Derived colours
These follow the knobs on their own. A theme normally never touches them.
| Token | Formula, all in oklab | Used for |
|---|---|---|
--bb-muted | mix(--bb-text 6%, --bb-panel) | Static subtle fills, disabled surfaces |
--bb-track | mix(--bb-text 15%, --bb-panel) | Recessed control tracks: switch and slider rails |
--bb-hover | mix(--bb-text 4%, --bb-panel) | Hover fill for affordances: buttons, nav links |
--bb-surface-hover | mix(--bb-text 2%, --bb-panel) | Hover fill for passive surfaces: rows, day cells |
--bb-pressed | mix(--bb-text 8%, --bb-panel) | Neutral pressed fill |
--bb-text-muted | mix(--bb-text 60%, transparent) | Secondary text, icons, placeholders |
--bb-text-faint | mix(--bb-text 45%, transparent) | Disabled foregrounds |
--bb-ring | mix(--bb-primary var(--bb-ring-opacity), transparent) | Focus ring, primary and link variants |
--bb-ring-danger | same over --bb-danger | Focus ring in error states |
--bb-ring-warn | same over --bb-warn | Focus ring in the warning state |
--bb-ring-neutral | same over --bb-text | Focus ring for neutral variants: grey, never primary |
They are tokens rather than inlined formulas for two reasons. They are consumed in dozens of places, and each one is a pinnable scale step. A theme that wants hand-tuned greys can override any of them, and a cheap theme keeps the derivation.
The 2/4/6/8 neutral-chrome percentages sit deliberately off the emphasis ladder
below. They live only in variables.css, which is the sanctioned home of the
derivation formulas.
Shape, scale and motion
| Token | Default | Notes |
|---|---|---|
--bb-radius | 8px | The knob: buttons, inputs, tab strips |
--bb-radius-surface | calc(var(--bb-radius) * 1.8) | Dialogs, off-canvas sheets |
--bb-radius-sm | max(4px, calc(var(--bb-radius) * 0.6)) | Nested bits: table controls, toast close |
--bb-border-w | 1px | |
--bb-ring-size | 2px | Focus ring thickness |
--bb-control-h | 32px | Master control height; buttons and tabs derive from it |
--bb-fs | 14px | Master font size; component sizes derive by calc() offsets |
--bb-panel-p | 16px | 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.
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.
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:
| State | Neutral chrome | Tinted variants | Solid variants |
|---|---|---|---|
| rest | transparent, or 8% if filled | 8% | 100% |
| hover | var(--bb-hover) | 15% | 90% + black |
| pressed | var(--bb-pressed) | 25% | 80% + black |
| disabled | var(--bb-muted), text at 45% | same | same |
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 column 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. Component tokens and ladder formulas
follow on their own, because they are declared on the components themselves.
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. Re-apply the light or dark class on that scope, or override the
derived tokens directly. Root-level theming is unaffected.
Writing a theme
A theme is two small blocks of the same ten or so declarations, one per
scheme. 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 CSS variables, not in the plugin config, so changing it needs no
rebuild.
Nine tokens are registered with @property: the eight colour knobs with
syntax: '<color>', and --bb-ring-opacity with syntax: '<percentage>'.
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. Put the
class on the same element as the toggle. The whole subtree repaints while it
runs, a deliberate cost, and that is why it is opt-in.
Overriding a component token
Component tokens are real affordances. Retune one instance with
<BbTabs style="--list-h: 40px">, or a whole page scope. 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.
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 unresolved string, because custom
properties get no var() substitution there. 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'; // known baseline, so a silent no-op is detectable
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)})`;
}
resolveCssColor(getComputedStyle(el).getPropertyValue('--bb-text-muted').trim());
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.