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):
| Theme | Mode | Feel |
|---|---|---|
paper | Light | Warm, default |
mist | Light | Cool, neutral |
charcoal | Dark | Warm dark |
slate | Dark | Cool, 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 paper ⇄ slate 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
| Token | Used for |
|---|---|
--background / --foreground | Page background and default text |
--card / --card-foreground | Raised surfaces — cards, panels |
--popover / --popover-foreground | Floating surfaces — menus, popovers |
--muted / --muted-foreground | Subtle backgrounds and secondary text |
Semantic colours
Each colour has a matching -foreground token for text/icons placed on it.
| Token | Used for |
|---|---|
--primary / --primary-foreground | Brand / primary actions (amber) |
--secondary / --secondary-foreground | Neutral, secondary actions |
--accent / --accent-foreground | Accent highlights |
--success / --success-foreground | Success states |
--info / --info-foreground | Informational states |
--warning / --warning-foreground | Warning states |
--help / --help-foreground | Help / tips |
--destructive / --destructive-foreground | Destructive / dangerous actions |
Borders & focus
| Token | Used for |
|---|---|
--border | Default border colour |
--input | Form-control borders |
--ring | Focus-ring colour |
Radius & motion (shared across all themes)
| Token | Default | Used for |
|---|---|---|
--radius | 0.625rem | Corner radius for every component |
--bpdm-ease-out | cubic-bezier(0.32, 0.72, 0, 1) | Confident decelerate |
--bpdm-ease-overshoot | cubic-bezier(0.21, 1.02, 0.73, 1) | Springy "pop" |
--bpdm-duration-fast | 120ms | Quick transitions |
--bpdm-duration-base | 180ms | Default transitions |
--bpdm-duration-slow | 240ms | Slower 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:
| Token | Default | Used for |
|---|---|---|
--bpdm-option-active-bg | color-mix(in oklab, var(--primary) 10%, transparent) | The tint behind the highlighted option |
--bpdm-option-active-bar | var(--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.