bpdm/ui
Getting Started

Configuration

Themes, dark mode, and customising the design tokens.

Everything visual in bpdm/ui is driven by the CSS variables in @bpdm/tokens, so theming is just setting (or overriding) those tokens — no config files, same for React and Angular.

Themes

bpdm ships four built-in themes, selected with a data-theme attribute on <html> (or any ancestor):

ThemeModeFeel
paperLightWarm, default
mistLightCool, neutral
charcoalDarkWarm dark
slateDarkCool, enterprise dark
<html data-theme="paper">  <!-- default light -->
<html data-theme="slate">  <!-- cool dark -->

If you don't set data-theme, the default (paper) applies.

Dark mode

Switch between a light and a dark theme by toggling data-theme — for example with a small theme switcher that flips paperslate and persists the choice:

const setTheme = (theme: 'paper' | 'slate') => {
  document.documentElement.dataset.theme = theme;
  localStorage.setItem('theme', theme);
};

Use any light theme (paper/mist) against any dark theme (charcoal/slate).

Overriding tokens

Re-theme by overriding any token — globally on :root, or scoped to a subtree. The brand colour (--primary) is amber in every theme; change it once and every component follows:

:root {
  --primary: #7c3aed;          /* your brand color    */
  --ring: #7c3aed;             /* matching focus ring */
  --primary-foreground: #fff;  /* text on primary     */
}

Each semantic colour also ships a paired -strong shade (--primary-strong, --destructive-strong, …) — the accessible, readable-as-text variant of the hue used wherever the colour itself carries meaning (e.g. a ghost/outline primary button label, coloured badge and alert text). Why it exists: a vivid brand hue like our amber --primary sits well below the WCAG 1.4.3 minimum (4.5:1) when used as text on a light background, so -strong is the darkened, contrast-safe step of the same colour — keeping the bright brand for fills while text/indicators stay legible. You don't normally set it: on light themes it's auto-derived from the base with color-mix (a slight oklab darken), and on dark themes it tracks the base directly — so overriding just --primary gives you a matching ≥4.5:1 text shade for free. Only override --primary-strong explicitly for an edge case: an unusually light brand colour whose auto-derived shade still wouldn't reach the contrast target (set a darker value), or a brand colour that's already dark enough to read as text where you don't want the extra darkening — in that case just point it at the base:

:root {
  --primary: #0b5cad; /* your already-accessible brand */
  --primary-strong: var(--primary); /* opt out of the auto-darken; strong == base */
}

Because it's a plain CSS custom property, you're never forced to invent a second colour — leave it for the free auto-derived shade, alias it to --primary to opt out, or set any exact value you want.

Design tokens

These are every token you can override. Colours are defined per theme; radius and motion are shared across all themes. Override any of them on :root (globally) or on any element (scoped).

Surfaces & text

TokenUsed for
--background / --foregroundPage background and default text
--card / --card-foregroundRaised surfaces — cards, panels
--popover / --popover-foregroundFloating surfaces — menus, popovers
--muted / --muted-foregroundSubtle backgrounds and secondary text

Semantic colours

Each colour has a matching -foreground token for text/icons placed on it.

TokenUsed for
--primary / --primary-foregroundBrand / primary actions (amber)
--secondary / --secondary-foregroundNeutral, secondary actions
--accent / --accent-foregroundAccent highlights
--success / --success-foregroundSuccess states
--info / --info-foregroundInformational states
--warning / --warning-foregroundWarning states
--help / --help-foregroundHelp / tips
--destructive / --destructive-foregroundDestructive / dangerous actions

Borders & focus

TokenUsed for
--borderDefault border colour
--inputForm-control borders
--ringFocus-ring colour

Radius & motion (shared across all themes)

TokenDefaultUsed for
--radius0.625remCorner radius for every component
--bpdm-ease-outcubic-bezier(0.32, 0.72, 0, 1)Confident decelerate
--bpdm-ease-overshootcubic-bezier(0.21, 1.02, 0.73, 1)Springy "pop"
--bpdm-duration-fast120msQuick transitions
--bpdm-duration-base180msDefault transitions
--bpdm-duration-slow240msSlower transitions

Reference these motion tokens in your own CSS (e.g. transition-duration: var(--bpdm-duration-base)) instead of hard-coding values, so custom UI matches the system.

Active option highlight (Select · Multi-Select · Tree-Select)

The keyboard-active (highlighted) row in these listboxes gets a soft brand tint plus an amber inline-start bar. Both parts are token-driven, so you can restyle — or remove — the treatment without touching component code:

TokenDefaultUsed for
--bpdm-option-active-bgcolor-mix(in oklab, var(--primary) 10%, transparent)The tint behind the highlighted option
--bpdm-option-active-barvar(--ring)The inline-start accent bar

Why the bar exists: a subtle brand tint alone sits at ~1.05:1 against the panel — too faint on its own to signal which option is active. The bar (built on --ring, measured ≥3:1 on the light themes) is what actually carries that contrast, so a low-vision or keyboard user can tell which option is focused. The tint is decorative on top.

Don't want the bar? Remove it — but keep the highlight perceivable by giving the fill enough contrast yourself:

:root {
  --bpdm-option-active-bar: transparent;                                  /* drop the bar */
  --bpdm-option-active-bg: color-mix(in oklab, var(--primary) 22%, transparent); /* stronger fill instead */
}

Opting out of the bar is a deliberate accessibility trade-off — the default is safe, and if you override it, meeting 3:1 is on you.

Next

On this page