Skip to content

Design tokens

The token architecture: two tiers, the brand knobs a theme sets, the ladder under every state.

On this page

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.

TokenLightDarkMeaning
--bb-primaryrgb(0 0 0)whiteThe brand accent
--bb-primary-fgwhiteblackText and icons on primary, auto-contrasted
--bb-dangerrgb(217 59 48)rose-400Errors, destructive intent
--bb-warnrgb(240 131 88)amber-500Warning chrome and icons
--bb-text-warnrgb(180 83 9)amber-500Warning message text
--bb-panelrgb(255 255 255)#313638Raised surfaces: cards, inputs, popovers, dialogs
--bb-textrgb(0 0 0)whiteForeground
--bb-borderzinc-300zinc-600Hairlines; pair with --bb-border-w
--bb-ring-opacity15%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.

TokenFormula, all in oklabUsed for
--bb-mutedmix(--bb-text 6%, --bb-panel)Static subtle fills, disabled surfaces
--bb-trackmix(--bb-text 15%, --bb-panel)Recessed control tracks: switch and slider rails
--bb-hovermix(--bb-text 4%, --bb-panel)Hover fill for affordances: buttons, nav links
--bb-surface-hovermix(--bb-text 2%, --bb-panel)Hover fill for passive surfaces: rows, day cells
--bb-pressedmix(--bb-text 8%, --bb-panel)Neutral pressed fill
--bb-text-mutedmix(--bb-text 60%, transparent)Secondary text, icons, placeholders
--bb-text-faintmix(--bb-text 45%, transparent)Disabled foregrounds
--bb-ringmix(--bb-primary var(--bb-ring-opacity), transparent)Focus ring, primary and link variants
--bb-ring-dangersame over --bb-dangerFocus ring in error states
--bb-ring-warnsame over --bb-warnFocus ring in the warning state
--bb-ring-neutralsame over --bb-textFocus 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

TokenDefaultNotes
--bb-radius8pxThe knob: buttons, inputs, tab strips
--bb-radius-surfacecalc(var(--bb-radius) * 1.8)Dialogs, off-canvas sheets
--bb-radius-smmax(4px, calc(var(--bb-radius) * 0.6))Nested bits: table controls, toast close
--bb-border-w1px
--bb-ring-size2pxFocus ring thickness
--bb-control-h32pxMaster control height; buttons and tabs derive from it
--bb-fs14pxMaster font size; component sizes derive by calc() offsets
--bb-panel-p16pxContent 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.

StopMeaningExample
8%Resting tint fillSecondary button background, tab strip
15%Hover tint fillSecondary and destructive button hover
25%Pressed or selected tint, tinted bordersaria-pressed background, danger border
45%Disabled foregroundDisabled button text
60%Muted text, which is --bb-text-mutedSecondary copy, icons
75%Strong but secondary foregroundToast icon, alert secondary copy

Two composition modes, chosen by context:

css
/* 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:

StateNeutral chromeTinted variantsSolid variants
resttransparent, or 8% if filled8%100%
hovervar(--bb-hover)15%90% + black
pressedvar(--bb-pressed)25%80% + black
disabledvar(--bb-muted), text at 45%samesame

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:

css
: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:

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

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