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.
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-utilityorwcag-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:
- 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. - Introduce the schema gate. Commit each
client.tokens.jsonand add the CI validation job in warning-only mode (process.exitCode = 0but still logs errors). This lets you see violations without breaking deploys immediately. - Switch to compiled output. Replace the hand-maintained fork with the compiled
dist/brands/<slug>.cssand delete the fork. Enable strictexit 1in 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.
Related
- White-Label Token Overrides — parent page covering the full override strategy
- Validating Design Tokens Against JSON Schema in CI — Ajv setup, schema draft versions, and CI integration patterns
- Theme Contract Versioning — managing breaking changes to the open-token schema across client releases