Skip to content

Design tokens

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

On this page

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. Light rgb(0 0 0), dark rgb(255 255 255).
  • --bb-primary-fg: text and icons on primary, auto-contrasted. Light rgb(255 255 255), dark rgb(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. Light rgb(217 59 48), dark rgb(251 113 133).
  • --bb-danger-fg: text and icons on a danger fill. Light rgb(255 255 255), dark rgb(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. Light rgb(240 131 88), dark rgb(245 158 11).
  • --bb-text-warn: warning message text. Light rgb(180 83 9), dark rgb(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. Light rgb(255 255 255), dark rgb(49 54 56).
  • --bb-text: the foreground. Light rgb(0 0 0), dark rgb(255 255 255).
  • --bb-border: hairlines; pair it with --bb-border-w. Light rgb(212 212 216), dark rgb(82 82 91).
  • --bb-ring-opacity: the strength of the focus ring. Light 15%, dark 40%.
  • --bb-secondary-tint: how much text tints --bb-secondary. Light 4%, dark 6%, 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-text 6% into --bb-panel, for static subtle fills and disabled surfaces.
  • --bb-track: --bb-text 15% into --bb-panel, for recessed control tracks such as switch and slider rails.
  • --bb-hover: --bb-text 4% into --bb-panel, the hover fill for affordances such as buttons and nav links.
  • --bb-surface-hover: --bb-text 2% into --bb-panel, the hover fill for passive surfaces such as rows and day cells.
  • --bb-pressed: --bb-text 8% into --bb-panel, the neutral pressed fill.
  • --bb-text-muted: --bb-text at 60% opacity, for secondary text, icons and placeholders.
  • --bb-text-faint: --bb-text at 45% opacity, for disabled foregrounds.
  • --bb-ring: --bb-primary at --bb-ring-opacity opacity, 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-secondary and its -hover, -pressed, -border, -fg and -ring: one value per state for the button plates. The same family exists for outline, ghost, link and destructive, plus --bb-primary-hover and -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, not 0.
  • --bb-ring-size (2px): the focus ring thickness.
  • --bb-elev (flat): the resting shadow on controls, a full box-shadow value.
  • --bb-shadow (soft, two layers): overlays such as dialogs, popovers, menus and toasts.
  • --bb-surface-filter (none): frost on raised plates, a full backdrop-filter value.
  • --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), dark 0.5): the backdrop dim, colour and opacity in one.
  • --bb-overlay-above (--bb-overlay): the dim under a dialog or sheet opened over another; transparent keeps one dim under the whole stack. A dialog that replaces a member of its own stack adds 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-weight is 500.
  • --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

This address is already taken.

Smaller description and message

This address is already taken.

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.

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:

  • 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 at 45%.

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:

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

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

Brand primary

Workspace settings

Pro plan

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:

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.

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.

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