Client Themes
The Tokens Theming Dimension — per-client semantic token overrides. Override matrix, minimal theme-{client}.css template, and interaction-state rules.
BDS ships 3 built-in themes. Each theme overrides a curated set of semantic color and typography tokens while leaving primitives and spacing untouched. The 8 numbered website template themes (theme-1 through theme-8) have moved to the brik/brik-website-themes repo and are not part of the client project architecture.
How themes work
Brand themes use a class on <body> plus the data-theme attribute on <html>. The ThemeProvider component manages both automatically.
// In your layout or app root:
<ThemeProvider initialTheme={{ themeNumber: 'brik-dark' }}>
<App />
</ThemeProvider>Under the hood:
ThemeProvideraddstheme-brand-brikto<body>and setsdata-theme="dark"(or"light") on<html>.- CSS selectors like
.theme-brand-brik[data-theme="dark"] { … }override the semantic tokens for that theme. - Brik's theme blocks live in
tokens/theme-brand-brik.css(bundled intodist/tokens.css); the client-sim block lives intokens/font-audit.css.
See tokens/CASCADE.md for the full load order.
Built-in themes
| Selector | Description |
|---|---|
.theme-brand-brik[data-theme="light"] (or no data-theme) | Brik brand — poppy red, Poppins on white |
.theme-brand-brik[data-theme="dark"] | Brik brand, dark mode — poppy red on near-black |
.theme-brand-brik.theme-client-sim | Font audit tool — Georgia / Verdana / Courier New exposes semantic font-family misuse |
Use the paintbrush icon in the Storybook toolbar to switch themes in real time.
Light and dark: one base mode per client
A client site ships one base mode. It does not get a light/dark toggle. company_profiles.theme_mode decides which way the client theme is built: light puts dark text on light surfaces, and dark puts light text on dark surfaces. The portal theme generator writes that one set of semantic tokens into @layer client-theme. It writes no [data-theme] or prefers-color-scheme block, so a visitor cannot switch the site to the other mode.
- The light/dark switch in How themes work belongs to Brik's own theme (
theme-brand-brik). Client themes don't inherit it. - A dark section on a light page, or the reverse, is not a second mode. The section reads the inverse tokens, such as
--background-inverse, which the generator writes for both base modes. - When
theme_modeis unset, the generator guesses: dark if the palette has ablackprimitive, otherwise light. That guess is often wrong, because most palettes include a black. Set the mode before you generate the theme. theme_modeis the client's default color mode. A second mode, which gives visitors a toggle, is built only when a client asks for one.- Higher contrast is reserved as its own switch, not a third mode. It isn't built yet. See
tokens/CASCADE.md§ Color-mode contract.
How the website builder's picks resolve to a mode
The website builder takes pick tags such as Modern, Minimal, Dark, spacious or expressive. One pure lookup in the portal resolves them: resolveDesignDecisionSet in src/lib/design/decision-set.ts. It makes no model call. Each tag belongs to a vocabulary, and only some vocabularies affect the mode.
| Vocabulary | Example tags | What it decides | Affects the mode? |
|---|---|---|---|
| Personality | Modern, Minimal, Bold, Luxury, Warm | Seeds an atmosphere | Indirectly, through the seeded atmosphere's paired mode |
| Visual style | Dark, Light, Minimal, Editorial | Style preference for briefs and blueprint scoring | Only Dark and Light, which set it directly |
| Atmosphere | editorial-luxury, warm-soft | Decoration layer | Indirectly, because each atmosphere has a paired mode |
| Theme mode | light, dark | Base mode | Yes, directly |
| Expression modes | compact, spacious, sharp, pill, flat | data-mode-* attributes on :root | No |
| Animation tier | subtle, moderate, expressive | Motion classes | No |
Some tags belong to more than one vocabulary. A free pick of Modern or Minimal counts as both a personality and a visual style. expressive is both an animation tier and a typography mode; a free pick reads it as the animation tier.
Order of precedence
The resolver settles theme mode in this order:
- The confirmed
company_profiles.theme_mode. - A
DarkorLightpick. - The paired mode of the resolved atmosphere (
ATMOSPHERE_THEME_MODE). - Unset. The generator then falls back to the
black-primitive guess above.
It settles atmosphere in this order:
- The confirmed
company_profiles.atmosphere. - An atmosphere picked by name.
- The atmosphere that the personality picks seed most often. When a mode is already set, only seeds that pair with that mode count.
- When only a mode is set, the fallback for that mode:
lightgivesclean-bright, anddarkgivescinematic-dramatic.
Personality seeds an atmosphere. It doesn't lock it. The seeds are:
| Personality | Seeded atmosphere | Paired mode |
|---|---|---|
| Modern | minimal-clinical | light |
| Minimal | minimal-clinical | light |
| Professional | minimal-clinical | light |
| Corporate | minimal-clinical | light |
| Warm | warm-soft | light |
| Approachable | warm-soft | light |
| Playful | clean-bright | light |
| Trustworthy | clean-bright | light |
| Bold | cinematic-dramatic | dark |
| Energetic | cinematic-dramatic | dark |
| Luxury | editorial-luxury | dark |
| Refined | editorial-luxury | dark |
| Authoritative | editorial-luxury | dark |
Who writes the stored values
theme_mode and atmosphere on company_profiles are what the generator reads. Two writers set them:
| Writer | When | What it writes |
|---|---|---|
| Design-direction task | Only when the field is unset | theme_mode from the brief's color direction, and atmosphere from the personality seed |
Homepage-approval gate (design_homepage_approval) | When the operator completes it | The operator's confirmed values. They overwrite whatever was there, and are prefilled from the approved variant. |
The company's Branding tab shows both values (Visual Direction).
Token cascade
The full cascade order in a consuming project's globals.css (order matters):
/* 1. Generated tokens (light + dark) plus Brik defaults */
@import '@brikdesigns/bds/tokens.css';
/* 2. Client brand overrides */
@import './styles/theme-{client}.css';
/* 3. Component CSS bundle */
@import '@brikdesigns/bds/styles.css';tokens.css bundles figma-tokens + figma-tokens-dark + figma-dark-corrections + theme-brand-brik + animations + motion-classes. Style Dictionary regenerates the figma layer; never edit those files manually. gap-fills.css (also bundled) holds interaction states, status tokens, and other semantics not yet promoted to Figma.
What each theme overrides
Each .body.theme-{name} block overrides a fixed set of semantic color tokens — the same tokens documented in Primitives → Color. These include:
--background-brand-primary,--background-brand-secondary--text-primary,--text-secondary,--text-muted,--text-brand-primary--surface-primary,--surface-secondary,--surface-navigation,--surface-brand-primary--border-primary,--border-secondary,--border-brand-primary--page-primary,--page-brand-primary
--surface-nav is a deprecated alias for --surface-navigation. Both resolve to the same value. Prefer --surface-navigation in new client overrides.
Typography tokens are not overridden by data-theme="light" vs data-theme="dark" — both share the Brik font stack. The theme-client-sim class demonstrates font family overrides only.
Spacing tokens are not theme-controlled — spacing comes from the Base/Spacious mode set on .body, not from theme classes.
Adding a custom client brand
Create a theme-{client}.css file in the consuming project and add it as the final import in globals.css (after the BDS cascade above). Override any semantic tokens at :root level:
/* theme-acme.css — all that's needed for a full rebrand */
:root {
/* Required — brand identity */
--bds-background-brand-primary: #2563EB;
--bds-text-brand-primary: #2563EB;
--bds-border-brand-primary: #2563EB;
--bds-surface-brand-primary: #EFF6FF;
/* Required if overriding brand-primary — interaction states */
--bds-background-brand-primary-hover: #1D4ED8;
--bds-background-brand-primary-pressed: #1E40AF;
/* Recommended — surfaces and page */
--bds-page-primary: #F8FAFC;
--bds-surface-primary: #FFFFFF;
--bds-surface-secondary: #F1F5F9;
/* Optional — custom fonts */
--bds-font-family-heading: 'Neue Haas Grotesk', sans-serif;
--bds-font-family-body: 'Inter', sans-serif;
--bds-font-family-label: 'Inter', sans-serif;
}No .body.theme-{name} class needed — the :root block wins via cascade order.
Per-audience scope binding
Some client sites carry multiple brand colors at once — Vale Partners has three audience verticals (Healthcare, Land, Commercial), each with its own brand hue; brikdesigns.com has service lines (Marketing, Back-Office, Product) that each need a different accent. The pattern below binds canonical brand tokens once per scope so components keep referencing the same canonical names while the page expresses N brand colors simultaneously.
The pattern
Re-bind --background-brand-primary, --text-brand-primary, --border-brand-primary (and their hover/pressed states) inside a CSS attribute selector. Components stay scope-blind — they keep reading the canonical token. Each subtree the attribute selects gets its own value resolved.
/* theme-vale.css — primitives, then canonical bindings, then scoped overrides */
:root {
/* Brand primitives — one ramp per client hue, follows the Figma 6-stop rail
(lightest, lighter, light, dark, darker, darkest). Documented in
/docs/foundation/color → "Tonal scale + interaction state". */
--vale-partners-olive: #698339;
--vale-partners-olive-lightest: #e8edd9;
--vale-partners-olive-lighter: #c2d09a;
--vale-partners-olive-light: #a3b66f;
--vale-partners-olive-dark: #4f6429;
--vale-partners-olive-darker: #3d4c23;
--vale-partners-olive-darkest: #2a3318;
--vale-partners-gold: #c49a2f;
--vale-partners-gold-lightest: #f4ecd2;
/* …rest of the gold ramp… */
--vale-partners-navy: #95afd4;
--vale-partners-navy-darker: #1c3252;
/* …rest of the navy ramp… */
/* Default brand binding (no audience selected) — components fall back here. */
--bds-background-brand-primary: var(--vale-partners-navy-darker);
--bds-text-brand-primary: var(--vale-partners-navy-darker);
--bds-border-brand-primary: var(--vale-partners-navy-darker);
}
/* Audience scopes — each one re-binds the canonical brand tokens inside its
subtree. Adding a fourth audience is one more block; nothing else changes. */
[data-audience='healthcare'] {
--bds-background-brand-primary: var(--vale-partners-olive);
--bds-text-brand-primary: var(--vale-partners-olive-darker);
--bds-border-brand-primary: var(--vale-partners-olive);
--bds-background-brand-primary-hover: var(--vale-partners-olive-dark);
--bds-background-brand-primary-pressed: var(--vale-partners-olive-darker);
}
[data-audience='land'] {
--bds-background-brand-primary: var(--vale-partners-gold);
--bds-text-brand-primary: var(--vale-partners-gold);
--bds-border-brand-primary: var(--vale-partners-gold);
--bds-background-brand-primary-hover: var(--vale-partners-gold-dark);
--bds-background-brand-primary-pressed: var(--vale-partners-gold-darker);
}
[data-audience='commercial'] {
--bds-background-brand-primary: var(--vale-partners-navy-darker);
--bds-text-brand-primary: var(--vale-partners-navy-darker);
--bds-border-brand-primary: var(--vale-partners-navy-darker);
--bds-background-brand-primary-hover: var(--vale-partners-navy-dark);
--bds-background-brand-primary-pressed: var(--vale-partners-navy-darkest);
}One page, three audiences
CSS custom properties scope to the element they're set on and cascade into every descendant. Three audience blocks side-by-side on a homepage are three independent subtrees — each resolves --background-brand-primary to its own value:
<section class="services-grid">
<article data-audience="healthcare">
<h3 style="color: var(--bds-text-brand-primary)">Healthcare</h3> <!-- olive -->
<button style="background: var(--bds-background-brand-primary)">Explore</button>
</article>
<article data-audience="land">
<h3 style="color: var(--bds-text-brand-primary)">Land</h3> <!-- gold -->
<button style="background: var(--bds-background-brand-primary)">Explore</button>
</article>
<article data-audience="commercial">
<h3 style="color: var(--bds-text-brand-primary)">Commercial</h3> <!-- navy -->
<button style="background: var(--bds-background-brand-primary)">Explore</button>
</article>
</section>Same component, three brand identities, one canonical token. No conditional rendering, no per-audience component variants. The whole page (<html data-audience="commercial">) or one column (<article data-audience="commercial">) can carry the scope — the cascade handles both.
Choosing the attribute name
Use a name that matches the semantic axis the site is organized by. The mechanism is identical; only the vocabulary differs.
| Site | Attribute | Scopes |
|---|---|---|
| Vale Partners | data-audience | healthcare, land, commercial |
| brikdesigns.com | data-service | marketing, back-office, product |
| Healthcare network | data-department | cardiology, pediatrics, oncology |
Pick one per site and document it in the client's theme-{client}.css. Don't mix attribute names within a single site — it makes the cascade harder to predict.
Connection to BCS industry packs
Industry packs in @brikdesigns/bds/content-system express the semantic vocabulary, not the colors. Categories that drive audience pathways carry an audienceId:
// real-estate-commercial.ts (excerpt)
servicesMegaMenu: {
categories: [
{ audienceId: 'healthcare', heading: 'Healthcare', icon: 'ph:stethoscope', items: [...] },
{ audienceId: 'land', heading: 'Land', icon: 'ph:tree', items: [...] },
{ audienceId: 'commercial', heading: 'Commercial', icon: 'ph:storefront', items: [...] },
],
}The blueprint that renders this menu binds each audienceId to a [data-audience=X] attribute on the rendered column; the client theme above does the rest. Pack data carries no color decisions — visual binding is exclusively a client-theme concern. Sites without per-audience theming (the default) render every column in the same canonical brand color; content structure stays intact, no visual loss.
Don't add --background-{role}-primary (e.g. --background-land-primary) to canonical. Canonical is brand-agnostic and stays small. Audience semantics belong in the scope selector, not in the token name. The pattern above is what gives the system its scale property — adding a client or audience is one CSS block, never a new canonical token.
Overridability matrix
Not every token is meant to vary per brand. Use the table to decide what to put in a client theme-{client}.css.
| Token group | Per-client override? | Notes |
|---|---|---|
Brand colors (--bds-background-brand-primary, --bds-text-brand-primary, --bds-surface-brand-primary, --bds-border-brand-primary) | Required | Core brand identity. |
Interaction states (--bds-background-brand-primary-hover, --bds-background-brand-primary-pressed) | Required if brand colors change | Defined at :root in gap-fills.css — not inside theme blocks. If you change brand without overriding these, hover/press keep the default Poppy tint. |
Surfaces + page (--bds-surface-primary, --bds-surface-secondary, --bds-page-primary) | Recommended | Tune neutrals to the brand. |
Fonts (--bds-font-family-heading, --bds-font-family-body, --bds-font-family-label) | Optional | See "Typography-only client override" below. |
Canonical status (--background/surface/text/border-positive/negative/warning) | Yes | First-class Figma variables. Override if the brand red/green/yellow clashes with the default. |
The info signal (--bds-background-info, --bds-surface-info, --bds-text-info) | No | Blue, universal semantic meaning. Gap-fills over the system blue primitive. |
The per-hue accent set (--text/background/border/surface/page-accent-{hue}) | No | Mirrored 1:1 from the ❖ Brik Brand Kit Figma library and Figma-sourced from the system color primitives, so accent-purple is the same purple in every theme. Category color, not a valence. red is the one exception — it aliases the brand poppy — see The per-hue accent set. |
Presence (--bds-background-presence-online/away/busy/offline) | No — intentionally frozen | Shared chat/status semantics across products. |
Shadows + overlay (--bds-shadow-sm..--bds-shadow-xl, --bds-background-overlay) | No | Hardcoded rgba() values; not expected to vary per brand. |
Interaction-state override example
:root {
--bds-background-brand-primary: #2563EB;
--bds-background-brand-primary-hover: #1D4ED8; /* must set manually */
--bds-background-brand-primary-pressed: #1E40AF; /* must set manually */
}Status token naming
Canonical names are --background/surface/text/border-positive/negative/warning (Figma-aligned). The older --*-status-* names (--background-status-error, --text-status-success, etc.) are deprecated backward-compat aliases — migrate to the canonical names.
Typography-only client override
To override fonts without changing any colors:
:root {
--bds-font-family-heading: 'Playfair Display', serif;
--bds-font-family-body: 'Source Sans 3', sans-serif;
--bds-font-family-label: 'Source Sans 3', sans-serif;
--bds-font-family-display: 'Playfair Display', serif;
}This is exactly what .body.theme-client-sim demonstrates in the toolbar.
Related
- Theming Overview — the four Theming Dimensions in context
- Atmospheres — the Atmospheres Dimension (CSS decoration overlays)
- Primitives → Color — the semantic tokens this layer overrides
Theming
Compose per-client brand identity across four orthogonal Theming Dimensions — Tokens, Atmospheres, Layout, Blueprints.
Atmospheres
The Atmospheres Theming Dimension — CSS decoration overlays that establish visual character (editorial-luxury, cinematic, warm-soft, and more) without duplicating CSS per client.