Brik Design System
Theming

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:

  • ThemeProvider adds theme-brand-brik to <body> and sets data-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 into dist/tokens.css); the client-sim block lives in tokens/font-audit.css.

See tokens/CASCADE.md for the full load order.

Built-in themes

SelectorDescription
.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-simFont 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_mode is unset, the generator guesses: dark if the palette has a black primitive, otherwise light. That guess is often wrong, because most palettes include a black. Set the mode before you generate the theme.
  • theme_mode is 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.

VocabularyExample tagsWhat it decidesAffects the mode?
PersonalityModern, Minimal, Bold, Luxury, WarmSeeds an atmosphereIndirectly, through the seeded atmosphere's paired mode
Visual styleDark, Light, Minimal, EditorialStyle preference for briefs and blueprint scoringOnly Dark and Light, which set it directly
Atmosphereeditorial-luxury, warm-softDecoration layerIndirectly, because each atmosphere has a paired mode
Theme modelight, darkBase modeYes, directly
Expression modescompact, spacious, sharp, pill, flatdata-mode-* attributes on :rootNo
Animation tiersubtle, moderate, expressiveMotion classesNo

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:

  1. The confirmed company_profiles.theme_mode.
  2. A Dark or Light pick.
  3. The paired mode of the resolved atmosphere (ATMOSPHERE_THEME_MODE).
  4. Unset. The generator then falls back to the black-primitive guess above.

It settles atmosphere in this order:

  1. The confirmed company_profiles.atmosphere.
  2. An atmosphere picked by name.
  3. The atmosphere that the personality picks seed most often. When a mode is already set, only seeds that pair with that mode count.
  4. When only a mode is set, the fallback for that mode: light gives clean-bright, and dark gives cinematic-dramatic.

Personality seeds an atmosphere. It doesn't lock it. The seeds are:

PersonalitySeeded atmospherePaired mode
Modernminimal-clinicallight
Minimalminimal-clinicallight
Professionalminimal-clinicallight
Corporateminimal-clinicallight
Warmwarm-softlight
Approachablewarm-softlight
Playfulclean-brightlight
Trustworthyclean-brightlight
Boldcinematic-dramaticdark
Energeticcinematic-dramaticdark
Luxuryeditorial-luxurydark
Refinededitorial-luxurydark
Authoritativeeditorial-luxurydark

Who writes the stored values

theme_mode and atmosphere on company_profiles are what the generator reads. Two writers set them:

WriterWhenWhat it writes
Design-direction taskOnly when the field is unsettheme_mode from the brief's color direction, and atmosphere from the personality seed
Homepage-approval gate (design_homepage_approval)When the operator completes itThe 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.

SiteAttributeScopes
Vale Partnersdata-audiencehealthcare, land, commercial
brikdesigns.comdata-servicemarketing, back-office, product
Healthcare networkdata-departmentcardiology, 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 groupPer-client override?Notes
Brand colors (--bds-background-brand-primary, --bds-text-brand-primary, --bds-surface-brand-primary, --bds-border-brand-primary)RequiredCore brand identity.
Interaction states (--bds-background-brand-primary-hover, --bds-background-brand-primary-pressed)Required if brand colors changeDefined 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)RecommendedTune neutrals to the brand.
Fonts (--bds-font-family-heading, --bds-font-family-body, --bds-font-family-label)OptionalSee "Typography-only client override" below.
Canonical status (--background/surface/text/border-positive/negative/warning)YesFirst-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)NoBlue, universal semantic meaning. Gap-fills over the system blue primitive.
The per-hue accent set (--text/background/border/surface/page-accent-{hue})NoMirrored 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 frozenShared chat/status semantics across products.
Shadows + overlay (--bds-shadow-sm..--bds-shadow-xl, --bds-background-overlay)NoHardcoded 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.

On this page

💬