Overriding Base Tokens for White-Label Clients

Part of White-Label Token Overrides. This page walks through building a safe, validated override file that a client edits—covering how to define an open-token schema, validate it in CI against JSON Schema and WCAG contrast, and compile it into a scoped CSS layer that falls back to your base tokens for anything the client leaves unspecified.

Validate → Compile → Fallback flow Three-stage pipeline: client fills an open-token JSON file, CI validates schema and WCAG contrast, the compiler emits a scoped CSS layer with base-token fallbacks. Client client.tokens.json open tokens only e.g. brand colors CI Validation JSON Schema (Ajv) WCAG contrast check Exit 1 on failure CSS Output [data-brand] scope in @layer brand var(--client-x, var(--base-x)) Base fallback chain Unspecified tokens resolve to base layer automatically
The three-stage override pipeline: client edits a constrained JSON file, CI validates schema and WCAG contrast, and the compiler emits a scoped CSS layer with automatic base-token fallbacks.

Prerequisites

  • Token tiers defined. Your base design system already separates primitive tokens (raw values like #0f172a) from semantic tokens (like --ds-color-action-primary). Clients override semantic tokens only; primitives stay internal.
  • Open-token list agreed. You have a curated list of tokens clients are permitted to change. Every other token is read-only.
  • Toolchain in place. Node.js ≥ 20, Ajv v8+, a CSS layer build step (Style Dictionary 4.x or a custom script), and a WCAG contrast library (@csstools/color-utility or wcag-contrast).
  • CI gate exists. A GitHub Actions (or equivalent) workflow already runs on pull requests. The validation step slots in before compile.
  • Scoped HTML attribute pattern. Your application renders brand-specific subtrees under [data-brand="<slug>"]. This is how the compiled CSS scopes overrides without touching global state.

Step 1 — Define the Open-Token Schema

Create schema/client-tokens.schema.json. This file is the contract: it lists every token a client may override and enforces value constraints so no invalid CSS reaches the browser.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "client-tokens",
  "title": "Client override token schema",
  "type": "object",
  "additionalProperties": false,
  "required": [],
  "properties": {
    "color": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "action-primary": {
          "type": "object",
          "additionalProperties": false,
          "required": ["value", "type"],
          "properties": {
            "value": {
              "type": "string",
              "pattern": "^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$"
            },
            "type": { "const": "color" },
            "description": { "type": "string" }
          }
        },
        "action-primary-text": {
          "type": "object",
          "additionalProperties": false,
          "required": ["value", "type"],
          "properties": {
            "value": {
              "type": "string",
              "pattern": "^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$"
            },
            "type": { "const": "color" }
          }
        },
        "brand-surface": {
          "type": "object",
          "additionalProperties": false,
          "required": ["value", "type"],
          "properties": {
            "value": {
              "type": "string",
              "pattern": "^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$"
            },
            "type": { "const": "color" }
          }
        }
      }
    },
    "typography": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "font-family-base": {
          "type": "object",
          "additionalProperties": false,
          "required": ["value", "type"],
          "properties": {
            "value": { "type": "string", "maxLength": 120 },
            "type": { "const": "fontFamily" }
          }
        }
      }
    }
  }
}

Why this works. additionalProperties: false at every level means a client cannot smuggle in tokens outside the open list. A typo in a key name fails validation outright instead of silently being ignored at compile time. The pattern constraints on color values prevent CSS injection through malformed strings.

Step 2 — Client Fills the Override File

Clients receive a template (client.tokens.json) pre-populated with their current values and comments explaining each token’s purpose. They edit only what they want to change and leave everything else out. Omitted tokens are not an error; the fallback chain (Step 5) handles them.

{
  "color": {
    "action-primary": {
      "value": "#b45309",
      "type": "color",
      "description": "Acme Corp primary CTA — warm amber"
    },
    "action-primary-text": {
      "value": "#ffffff",
      "type": "color"
    },
    "brand-surface": {
      "value": "#fef9f0",
      "type": "color"
    }
  }
}

Keep the template in version control under clients/<slug>/client.tokens.json. PRs against that path trigger the CI validation job. Clients who work through a portal can have their UI POST a validated payload to a merge-request API that opens the PR automatically.

Step 3 — Validate in CI (Schema + WCAG Contrast)

The validation script does two things in sequence: JSON Schema structural check, then WCAG contrast check on every foreground/background pair the schema declares as needing one. A single process.exit(1) on any failure blocks the PR merge. See the companion guide on validating design tokens against JSON Schema in CI for Ajv setup details.

// scripts/validate-client-tokens.js
const Ajv = require("ajv/dist/2020");
const fs = require("fs");
const path = require("path");
const { contrast, parseHex } = require("@csstools/color-utility");

const ajv = new Ajv({ allErrors: true });
const schema = JSON.parse(
  fs.readFileSync(
    path.resolve(__dirname, "../schema/client-tokens.schema.json"),
    "utf8"
  )
);
const validate = ajv.compile(schema);

// Load client file from CLI arg: node validate-client-tokens.js clients/acme/client.tokens.json
const clientFile = process.argv[2];
if (!clientFile) {
  console.error("Usage: node validate-client-tokens.js <path-to-client.tokens.json>");
  process.exit(1);
}

const tokens = JSON.parse(fs.readFileSync(clientFile, "utf8"));
let hasErrors = false;

// 1. Schema validation
if (!validate(tokens)) {
  console.error("Schema validation failed:");
  console.error(JSON.stringify(validate.errors, null, 2));
  hasErrors = true;
}

// 2. WCAG contrast check — pairs that must meet AA (4.5:1)
const CONTRAST_PAIRS = [
  ["color.action-primary-text", "color.action-primary"],
];

const resolve = (tokens, dotPath) => {
  const parts = dotPath.split(".");
  let node = tokens;
  for (const p of parts) {
    if (!node || !Object.prototype.hasOwnProperty.call(node, p)) return null;
    node = node[p];
  }
  return node && node.value ? node.value : null;
};

for (const [fgPath, bgPath] of CONTRAST_PAIRS) {
  const fg = resolve(tokens, fgPath);
  const bg = resolve(tokens, bgPath);
  // Skip the pair if the client didn't supply both — base tokens handle it
  if (!fg || !bg) continue;

  const ratio = contrast(parseHex(fg), parseHex(bg));
  if (ratio < 4.5) {
    console.error(
      `WCAG AA contrast failure: ${fgPath} (${fg}) on ${bgPath} (${bg}) = ${ratio.toFixed(2)}:1 (need ≥ 4.5:1)`
    );
    hasErrors = true;
  }
}

process.exit(hasErrors ? 1 : 0);

Wire it into GitHub Actions:

# .github/workflows/validate-client-tokens.yml
name: Validate Client Token Overrides
on:
  pull_request:
    paths:
      - "clients/**/client.tokens.json"

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "22"
          cache: "npm"
      - run: npm ci
      - name: Validate changed client token files
        run: |
          git diff --name-only origin/${{ github.base_ref }}...HEAD \
            | grep 'clients/.*/client.tokens.json' \
            | xargs -I{} node scripts/validate-client-tokens.js {}

Why this works. Running only on changed paths keeps the job fast even with hundreds of client files in the repository. The xargs pattern means one invalid file fails the job without skipping later files in the same PR—all errors surface in a single run.

Step 4 — Compile to a Scoped [data-brand] CSS Layer

After CI passes, a compile step converts each client.tokens.json into a CSS file scoped under [data-brand="<slug>"] and placed inside a @layer brand declaration. The compiler reads the client file and emits only the tokens that are present; it does not touch base-layer declarations.

// scripts/compile-client-tokens.js
const fs = require("fs");
const path = require("path");

const clientFile = process.argv[2]; // e.g. clients/acme/client.tokens.json
const slug = process.argv[3];       // e.g. acme

const tokens = JSON.parse(fs.readFileSync(clientFile, "utf8"));

// Map open token keys to CSS custom property names
const TOKEN_MAP = {
  "color.action-primary":      "--ds-color-action-primary",
  "color.action-primary-text": "--ds-color-action-primary-text",
  "color.brand-surface":       "--ds-color-brand-surface",
  "typography.font-family-base": "--ds-font-family-base",
};

const resolve = (obj, dotPath) => {
  const parts = dotPath.split(".");
  let node = obj;
  for (const p of parts) {
    if (!node || !Object.prototype.hasOwnProperty.call(node, p)) return null;
    node = node[p];
  }
  return node && node.value != null ? node.value : null;
};

const declarations = [];
for (const [tokenPath, cssProp] of Object.entries(TOKEN_MAP)) {
  const value = resolve(tokens, tokenPath);
  if (value !== null) {
    declarations.push(`  ${cssProp}: ${value};`);
  }
}

if (declarations.length === 0) {
  console.warn(`No overrides found in ${clientFile}; skipping output.`);
  process.exit(0);
}

const css = `@layer brand {
  [data-brand="${slug}"] {
${declarations.join("\n")}
  }
}
`;

const outDir = path.resolve(__dirname, `../dist/brands`);
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(path.join(outDir, `${slug}.css`), css, "utf8");
console.log(`Wrote dist/brands/${slug}.css (${declarations.length} override(s))`);

The output for the Acme example above is:

@layer brand {
  [data-brand="acme"] {
    --ds-color-action-primary: #b45309;
    --ds-color-action-primary-text: #ffffff;
    --ds-color-brand-surface: #fef9f0;
  }
}

Why this works. @layer brand sits above @layer base in the layer order declared at the top of your stylesheet, so client declarations win without needing higher specificity. The attribute selector [data-brand="acme"] ensures overrides are lexically scoped to that brand’s subtree and never bleed into other tenants rendered on the same page.

Step 5 — Build the Fallback Chain in Base CSS

The base stylesheet declares the layer order and defines every token inside @layer base. Components reference tokens through a two-level var() chain: the client-scoped custom property first, then the base default. This is the mechanism that makes partial overrides work without any JavaScript.

/* src/tokens/base.css */
@layer base, brand;

@layer base {
  :root {
    /* Primitive values — not overridable by clients */
    --ds-primitive-amber-700: #b45309;
    --ds-primitive-white: #ffffff;
    --ds-primitive-slate-50: #f8fafc;
    --ds-primitive-blue-600: #2563eb;

    /* Semantic defaults — clients may override these via @layer brand */
    --ds-color-action-primary: var(--ds-primitive-blue-600);
    --ds-color-action-primary-text: var(--ds-primitive-white);
    --ds-color-brand-surface: var(--ds-primitive-slate-50);
    --ds-font-family-base: system-ui, sans-serif;
  }
}

Components then consume tokens without knowing which layer resolved them:

@layer components {
  .btn--primary {
    background-color: var(--ds-color-action-primary);
    color: var(--ds-color-action-primary-text);
  }

  .brand-surface {
    background-color: var(--ds-color-brand-surface);
    font-family: var(--ds-font-family-base);
  }
}

Why this works. The @layer base, brand; declaration at the top of the cascade means @layer brand always wins over @layer base for the same property on the same element, regardless of source order. Tokens the client does not supply remain unset in @layer brand, so the browser naturally resolves them from @layer base. No JavaScript, no conditional logic at runtime.

Verification

Computed-Style Check

After compiling dist/brands/acme.css, load it in Playwright and assert that the overridden tokens resolve to client values while unspecified tokens still resolve to base defaults.

// tests/brand-override.spec.js
import { test, expect } from "@playwright/test";

test("Acme brand: overridden and fallback tokens resolve correctly", async ({ page }) => {
  await page.goto("/brand-preview/acme");

  const styles = await page.evaluate(() => {
    const el = document.querySelector("[data-brand='acme']");
    const cs = getComputedStyle(el);
    return {
      actionPrimary: cs.getPropertyValue("--ds-color-action-primary").trim(),
      // typography.font-family-base was not overridden — should be base default
      fontFamily: cs.getPropertyValue("--ds-font-family-base").trim(),
    };
  });

  // Client override applied
  expect(styles.actionPrimary).toBe("#b45309");
  // Base fallback intact for non-overridden token
  expect(styles.fontFamily).toBe("system-ui, sans-serif");
});

Failing PR Example

A PR that sets "action-primary": { "value": "#f0f0f0" } (near-white) with "action-primary-text": { "value": "#ffffff" } (white) produces this CI output and blocks merge:

WCAG AA contrast failure: color.action-primary-text (#ffffff) on color.action-primary (#f0f0f0) = 1.07:1 (need ≥ 4.5:1)
Error: Process completed with exit code 1.

The PR author sees the ratio and the requirement in one line, with no need to run a contrast checker manually.

Troubleshooting

Symptom Likely Cause Fix
CI passes but browser renders base token, not client value dist/brands/<slug>.css not loaded on the page Confirm the brand CSS file is imported after base.css; verify [data-brand] attribute is present in the HTML
Schema validation error on a key the client didn’t touch PR includes auto-generated changes outside the open-token set Regenerate the client file from the template; ensure the client portal POST only writes declared paths
WCAG check skips a pair even though both values are present Key path in CONTRAST_PAIRS doesn’t match schema key names Run console.log(JSON.stringify(tokens, null, 2)) in the script to confirm the parsed structure
@layer brand declarations don’t win over @layer base Layer order @layer base, brand; is missing or declared after the brand layer is used Move the @layer base, brand; declaration to the very first CSS line the browser parses
Client override bleeds into adjacent [data-brand] subtrees Multiple brand CSS files concatenated without scoped selectors Ensure each dist/brands/<slug>.css uses [data-brand="<slug>"], not :root

Migration Note

If you currently maintain per-client CSS forks—separate stylesheets where a developer hand-edits hex values for each client—migrate in three phases:

  1. Inventory. For each fork, diff it against the base stylesheet to extract the set of properties that actually differ. This becomes the client’s initial client.tokens.json.
  2. Introduce the schema gate. Commit each client.tokens.json and add the CI validation job in warning-only mode (process.exitCode = 0 but still logs errors). This lets you see violations without breaking deploys immediately.
  3. Switch to compiled output. Replace the hand-maintained fork with the compiled dist/brands/<slug>.css and delete the fork. Enable strict exit 1 in CI. From this point, all client changes go through the validated override flow.

Teams that skip phase 2 and cut over directly often discover that existing per-client overrides fail WCAG validation. Phase 2 gives you a grace period to fix those failures before they become CI blockers. For a broader look at how override layers interact with version bumps when the base schema evolves, see versioning theme contracts across brands.