White-Label Token Overrides: A Safe Client Customization Surface
Part of Multi-Brand & White-Label Token Architecture. This page covers the architectural problem of letting white-label clients customize a product’s appearance without giving them unrestricted access to every token in the system — and without forking the design system every time a new client signs a contract.
The core tension is real: clients want brand fidelity; you want a single codebase. The answer is a controlled override surface — a published schema that defines exactly which tokens clients may supply, which tokens the platform owns and locks, and a pipeline that validates client input before it ever reaches production CSS.
Problem Framing
A white-label SaaS product ships to forty clients. Client A wants their brand blue. Client B wants rounded cards and a custom logo. Client C’s legal team has demanded specific typeface licensing. The naive answer — “give clients a CSS file to override whatever they want” — destroys any hope of consistent accessibility, predictable layout, and sane support contracts. Within two release cycles you are maintaining forty divergent stylesheets.
The production failure mode is specific: a client overrides --ds-spacing-base to collapse spacing on mobile, breaking touch targets below 44 × 44px and failing WCAG 2.5.5. Another client sets a low-contrast foreground color and ships it to ten thousand users before your team notices. Neither failure needed to happen. Both were preventable with a schema that made those tokens non-negotiable.
The pattern described here treats white-label customization as a published API: versioned, typed, validated, and audited in CI.
Open vs Locked Tokens
The first architectural decision is drawing the boundary. There is no universal rule, but the heuristic is: a token is open if changing it cannot break accessibility guarantees, layout invariants, or cross-component contracts.
Open (client-overridable) tokens
| Token category | Example variable | Rationale |
|---|---|---|
| Brand primary color | --ds-color-brand-primary |
Drive accent; validated for contrast against surfaces |
| Brand surface tint | --ds-color-surface-tinted |
Light wash behind cards; contrast audited automatically |
| Border radius | --ds-radius-base |
Cosmetic; no a11y implication if clamped |
| Headline font family | --ds-font-family-display |
Self-hosted fonts allowed via font-src CSP directive |
| Focus ring color | --ds-color-focus-ring |
Open, but minimum contrast enforced in validation |
| Logo URL | --ds-asset-logo |
CSS content or background-image; validated as URL |
Locked (platform-owned) tokens
| Token category | Example variable | Why locked |
|---|---|---|
| Spacing rhythm | --ds-spacing-base |
Breaks grid, touch targets, and component sizing |
| Minimum contrast ratios | --ds-color-text-on-surface |
Direct WCAG compliance dependency |
| Focus outline width | --ds-focus-width |
Accessibility: must meet visible focus requirements |
| Minimum touch target | --ds-size-touch-min |
WCAG 2.5.5; cannot be reduced |
| Type scale ratios | --ds-type-scale-ratio |
Fluid type relies on the modular ratio; changing it breaks clamped scales |
| Z-index system | --ds-z-* |
Cross-component stacking contracts; breaking this causes modal/tooltip bugs |
The locked list is finite and should be shipped as part of your override schema under a "locked" key so clients see exactly what is off-limits, with a short rationale per token.
Published Override Schema
The override surface should be published as a JSON Schema document in your design token package. Clients submit an override file that conforms to this schema; the pipeline rejects non-conforming files before compilation starts.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://tokens.example.com/schemas/client-override/v2.json",
"title": "Client Token Override Schema v2",
"description": "Defines permissible white-label token overrides. Tokens not listed here are platform-locked and cannot be changed.",
"type": "object",
"additionalProperties": false,
"properties": {
"$meta": {
"type": "object",
"required": ["clientId", "schemaVersion"],
"properties": {
"clientId": { "type": "string", "pattern": "^[a-z0-9-]{3,64}$" },
"schemaVersion":{ "type": "string", "enum": ["v2"] },
"displayName": { "type": "string", "maxLength": 80 }
},
"additionalProperties": false
},
"color": {
"type": "object",
"additionalProperties": false,
"properties": {
"brandPrimary": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" },
"brandSecondary":{ "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" },
"surfaceTinted": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" },
"focusRing": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" }
}
},
"radius": {
"type": "object",
"additionalProperties": false,
"properties": {
"base": {
"type": "string",
"description": "CSS length. Clamped to 0px–24px by the compiler.",
"pattern": "^\\d+(\\.\\d+)?(px|rem)$"
}
}
},
"typography": {
"type": "object",
"additionalProperties": false,
"properties": {
"displayFamily": {
"type": "string",
"maxLength": 128,
"description": "CSS font-family string for headings only."
}
}
},
"asset": {
"type": "object",
"additionalProperties": false,
"properties": {
"logoUrl": {
"type": "string",
"format": "uri",
"pattern": "^https://",
"description": "HTTPS URL to client logo (SVG or WebP ≤ 200KB)."
}
}
}
},
"required": ["$meta"]
}
The additionalProperties: false constraint at every object level is the schema’s primary enforcement mechanism: any attempt to include a token not in the schema — including locked tokens — causes validation to fail immediately. This is exactly what JSON Schema validation for design tokens recommends for compile-time safety.
Trade-Offs: Flexibility vs Brand Safety vs Support Burden
Designing the override surface forces three-way trade-offs that no schema can resolve automatically. Teams should record their decisions explicitly.
Flexibility vs brand safety
- More open tokens attract more enterprise clients and accelerate deal velocity. Fewer locked tokens mean more client autonomy.
- More open tokens mean more combinations to test, more accessibility failures to catch, and a larger validation surface. Every token you expose is one you are contractually responsible for validating correctly across every release.
- Practical middle ground: open brand colors plus cosmetic radius; lock everything that touches the WCAG-required interaction model (spacing, touch targets, focus indication).
Schema strictness vs client expressiveness
additionalProperties: falseprevents forward-compatibility mistakes but rejects any token the client tries to include that isn’t yet in the schema. Clients will ask for tokens not yet open. You need a formal request process (pull request against the schema, design review, accessibility sign-off) rather than an ad-hoc CSS patch.- Looser schemas (e.g., allowing
patternPropertieson arbitrary color tokens) increase expressiveness at the cost of requiring deeper audit coverage.
Validation coverage vs CI runtime
- Running a full WCAG contrast audit against every color pair in every component for every client on every commit is expensive. Scope contrast audits to the tokens that feed text-on-background relationships, not decorative colors.
- A static matrix (which semantic token uses which client-supplied color as a background) lets you enumerate only the pairs that matter, cutting contrast-check runtime from O(tokens²) to O(semantic text tokens).
Support burden vs override granularity
- Fine-grained overrides (exposing twenty color tokens) let clients achieve accurate brand fidelity but generate twenty × N support permutations. Coarse overrides (one
brandPrimaryfeeds all accent surfaces through a derived palette) reduce support cases but force clients to accept the algorithm’s output. - Derived palette generation is addressable: run an OKLCH lightness derivation at build time from
brandPrimaryto compute tints and shades automatically, then never expose those computed tokens as overridable. See theme contract versioning for how to manage derived-token evolution across schema versions.
Build Pipeline: Override File to Compiled CSS
The following numbered pipeline describes the full path from a client’s submitted JSON file to a scoped CSS layer ready to serve.
Step 1 — Receive the override file. The client submits acme-corp.override.json through a self-service portal or a CI-triggerable API endpoint. The file is stored in version control under clients/acme-corp/ so changes are auditable.
Step 2 — Schema validation. Run ajv validate against the published schema JSON. Any additionalProperties violation, type mismatch, or malformed value (e.g., a hex color that is five characters) exits non-zero and surfaces the AJV error report directly in the CI log. No further steps execute.
Step 3 — Locked-token guard. A secondary script asserts that no key in the override file corresponds to a token in the locked list. This is belt-and-suspenders: additionalProperties: false already blocks unknown keys, but the guard provides an explicit error message pointing the client to the schema documentation rather than a cryptic JSON Schema path.
Step 4 — Contrast audit. For every color token in the override, resolve which semantic text tokens reference it as a background and compute WCAG 2.1 relative luminance contrast ratios. Reject any combination below 4.5:1 for normal text and 3:1 for large text. The structured approach to semantic color tokens for accessibility describes how to model those background–foreground pairs in the token graph so the contrast checker can traverse them automatically.
Step 5 — Fallback resolution. Merge the validated client overrides with the base token file. Any token the client did not supply falls back to the platform default. The merge is shallow: only the exact keys listed in the client file are replaced. This produces a complete, resolved token map — no undefined references.
Step 6 — Compiler pass. Feed the resolved token map to Style Dictionary (or Cobalt) with a client-scoped output configuration. The output is a CSS file whose declarations are wrapped in @layer client-brand and scoped to [data-tenant="acme-corp"]. Primitive tokens that clients cannot touch are emitted in a higher-priority @layer base in the main bundle, so they can never be overridden by cascade order alone.
Step 7 — Asset serving. The compiled CSS file is hashed and published to the CDN under the client ID. The application loads it via a <link> element injected based on req.tenant at the edge, as described in per-tenant runtime theming with CSS variables.
// scripts/compile-client-override.mjs
import Ajv from 'ajv/dist/2020.js';
import addFormats from 'ajv-formats';
import { readFileSync, writeFileSync } from 'node:fs';
import StyleDictionary from 'style-dictionary';
const ajv = new Ajv({ allErrors: true });
addFormats(ajv);
const schema = JSON.parse(readFileSync('./schemas/client-override/v2.json', 'utf8'));
const override = JSON.parse(readFileSync(process.argv[2], 'utf8'));
const validate = ajv.compile(schema);
if (!validate(override)) {
console.error('Override schema validation failed:');
console.error(ajv.errorsText(validate.errors, { separator: '\n' }));
process.exit(1);
}
// Locked-token guard (belt + suspenders)
const LOCKED_KEYS = new Set(['spacing', 'touchTarget', 'focusWidth', 'typeScaleRatio', 'zIndex']);
const touchedLocked = Object.keys(override).filter(k => LOCKED_KEYS.has(k));
if (touchedLocked.length > 0) {
console.error(`Override attempts to set locked tokens: ${touchedLocked.join(', ')}`);
console.error('See https://tokens.example.com/schemas/client-override#locked for details.');
process.exit(1);
}
// Merge: base tokens + validated overrides
const base = JSON.parse(readFileSync('./tokens/base.tokens.json', 'utf8'));
const resolved = deepMerge(base, flattenOverride(override));
// Style Dictionary: emit @layer client-brand scoped to [data-tenant]
const sd = new StyleDictionary({
tokens: resolved,
platforms: {
css: {
transformGroup: 'css',
prefix: 'ds',
files: [{
destination: `dist/clients/${override.$meta.clientId}.css`,
format: 'css/variables',
options: {
selector: `[data-tenant="${override.$meta.clientId}"]`,
outputReferences: false,
},
}],
},
},
});
await sd.buildAllPlatforms();
console.log(`Built: dist/clients/${override.$meta.clientId}.css`);
Validation and Quality Gates
CI configuration
# .github/workflows/client-override-ci.yml
name: Client Override Validation
on:
push:
paths:
- 'clients/**/*.override.json'
- 'schemas/client-override/**'
jobs:
validate-overrides:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '22', cache: 'npm' }
- run: npm ci
- name: Schema + locked-token validation
run: |
for f in clients/**/*.override.json; do
echo "Validating $f"
node scripts/compile-client-override.mjs "$f" --dry-run
done
- name: WCAG contrast audit
run: |
node scripts/contrast-audit.mjs \
--overrides clients/**/*.override.json \
--token-graph tokens/semantic-graph.json \
--min-normal 4.5 \
--min-large 3.0 \
--fail-on-violation
- name: Compile all client CSS
run: node scripts/build-all-clients.mjs
- name: Upload client CSS artifacts
uses: actions/upload-artifact@v4
with:
name: client-css
path: dist/clients/
The contrast audit step uses a pre-built semantic graph (tokens/semantic-graph.json) that maps every client-overridable background token to the foreground tokens that are painted on top of it. This avoids re-parsing components at runtime: the graph is generated once from component source and committed.
Tool table
| Tool | Purpose | Integration point |
|---|---|---|
| AJV (v8, draft 2020-12) | JSON Schema validation with full error paths | Step 2 of pipeline, pre-commit hook |
contrast-audit.mjs (custom) |
WCAG contrast traversal against semantic token graph | CI job after schema validation |
| Style Dictionary v4 | Token compilation with @layer and [data-tenant] selector scoping |
Step 6 of pipeline |
ajv-formats |
URI and pattern-format validation (logoUrl, hex colors) |
AJV plugin, loaded alongside schema |
| Chromatic | Visual regression snapshots per client variant | Post-compile, on PR merge to main |
Cross-Cluster Dependency Mapping
| Related area | Sibling / dependent topic | Integration point | Validation strategy |
|---|---|---|---|
| Multi-Brand & White-Label Token Architecture | Theme Contract Versioning | Schema $id versioning; schemaVersion field in override meta |
Semver bump on any change to open/locked boundary; CI checks override file schemaVersion matches current |
| Token Scaling & Validation | JSON Schema Validation for Tokens | AJV compile step; shared $defs in base schema |
Schema unit tests in test/schema/; AJV strict mode in CI |
| Token Fundamentals | Semantic color tokens for accessibility | Semantic token graph used in contrast audit | Automated WCAG 4.5:1 / 3:1 checks; fail pipeline on violation |
| White-Label implementation | Overriding base tokens for white-label clients | Detailed token-by-token override mechanics | Manual review + schema compliance |
/* @depends: /multi-brand-theming-white-label-token-architecture/theme-contract-versioning/ */
/* @depends: /token-scaling-validation-ci-pipelines/json-schema-validation-for-tokens/ */
/* Generated by compile-client-override.mjs — DO NOT EDIT MANUALLY */
@layer client-brand {
[data-tenant="acme-corp"] {
--ds-color-brand-primary: #1a56db;
--ds-color-surface-tinted: #eff6ff;
--ds-color-focus-ring: #1a56db;
--ds-radius-base: 6px;
--ds-font-family-display: "Inter", system-ui, sans-serif;
}
}
/* Base layer wins for locked tokens regardless of cascade order */
@layer base {
:root {
--ds-spacing-base: 4px; /* locked — platform rhythm */
--ds-size-touch-min: 44px; /* locked — WCAG 2.5.5 */
--ds-focus-width: 2px; /* locked — visible focus */
}
}
Because @layer base is declared after @layer client-brand in the main bundle, base-layer declarations win the cascade for any locked token the client might accidentally duplicate in their file. The schema guard catches it first; the layer order catches anything that slips through.
Production Code Reference
Fallback chain in CSS
/* tokens/client-resolved.css — emitted per client */
/* 1. Primitive defaults (always present, never client-overridable) */
@layer primitives {
:root {
--primitive-blue-600: #2563eb;
--primitive-slate-50: #f8fafc;
}
}
/* 2. Semantic base (platform defaults, overridable by client layer) */
@layer base {
:root {
--ds-color-brand-primary: var(--primitive-blue-600);
--ds-color-surface-default: var(--primitive-slate-50);
--ds-radius-base: 4px;
--ds-font-family-display: system-ui, sans-serif;
}
}
/* 3. Client overrides (only open tokens, scoped to tenant attribute) */
@layer client-brand {
[data-tenant="acme-corp"] {
/* client's brandPrimary: "#1a56db" passed schema + contrast audit */
--ds-color-brand-primary: #1a56db;
--ds-radius-base: 6px;
--ds-font-family-display: "Inter", system-ui, sans-serif;
}
}
The resolution order — primitives → base → client-brand — ensures that a client who supplies only brandPrimary gets every other token from the platform default without any undefined references. Missing keys in the client override file are not errors; they are intentional gaps that the fallback chain fills automatically.
Contrast audit script (simplified)
// scripts/contrast-audit.mjs
// Traverses the semantic token graph to find text/background pairs
// that use client-supplied colors, then checks WCAG contrast ratios.
import { readFileSync } from 'node:fs';
import { glob } from 'glob';
const graph = JSON.parse(readFileSync(process.argv[3] || 'tokens/semantic-graph.json', 'utf8'));
const overridePaths = await glob('clients/**/*.override.json');
const MIN_NORMAL = 4.5;
const MIN_LARGE = 3.0;
let failed = false;
for (const path of overridePaths) {
const override = JSON.parse(readFileSync(path, 'utf8'));
const clientId = override.$meta.clientId;
for (const [clientKey, clientValue] of Object.entries(override.color ?? {})) {
const pairs = graph.backgroundPairs[clientKey] ?? [];
for (const pair of pairs) {
const fg = pair.foreground ?? '#0f172a';
const ratio = wcagContrastRatio(clientValue, fg);
const threshold = pair.largeText ? MIN_LARGE : MIN_NORMAL;
if (ratio < threshold) {
console.error(
`[${clientId}] CONTRAST FAIL: ${clientKey}="${clientValue}" vs "${fg}" → ${ratio.toFixed(2)}:1 (need ${threshold}:1) [${pair.context}]`
);
failed = true;
}
}
}
}
if (failed) process.exit(1);
console.log('All contrast audits passed.');
function wcagContrastRatio(hex1, hex2) {
const l1 = relativeLuminance(hex1);
const l2 = relativeLuminance(hex2);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
function relativeLuminance(hex) {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
}
function linearize(c) {
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
Diagnostic Matrix
| Diagnostic step | Execution detail |
|---|---|
| Override file rejected by CI | Run node scripts/compile-client-override.mjs clients/acme-corp.override.json locally. AJV will print the exact JSON path and constraint that failed. |
| Contrast audit failure | Check the CI log for CONTRAST FAIL lines. The log prints the token name, client-supplied color, foreground it conflicts with, and the computed ratio vs required threshold. |
| Client CSS not loading | Confirm the [data-tenant] attribute is set on <html> at the edge before HTML is streamed. The CSS selector is exact-match; a missing or misspelled tenant ID silently falls back to the base theme. |
| Locked token appears in client CSS | A locked token in the compiled output means the locked-token guard in compile-client-override.mjs was bypassed (e.g., someone copied a file directly). Add a post-compile assertion that greps the emitted CSS for locked variable names. |
| Schema version mismatch | CI rejects files where $meta.schemaVersion does not match the current schema version. Clients must migrate their override file when the schema increments. Publish a migration guide per schema version bump. |
Common root causes and resolutions
Client override file fails with additionalProperties error. The client included a token not in the open list — typically a spacing or z-index token they found by inspecting the browser DevTools. Point them to the published schema. If the request is legitimate, open a schema change request and run it through the design review process.
Contrast audit passes locally but fails in CI. The local script is using a different version of the semantic graph. Regenerate tokens/semantic-graph.json from current component source (node scripts/generate-token-graph.mjs) and commit the updated graph before re-running the audit.
Base-layer locked token appears overridden in the browser. An inline style attribute on a component is setting the locked token directly, bypassing the layer system entirely. Inline styles win over @layer unconditionally. Audit components for hard-coded inline styles that reference locked tokens; replace them with semantic token references.
Client reports brand color is not applied to all components. Some components reference the primitive token directly (e.g., --primitive-blue-600) rather than the semantic alias (--ds-color-brand-primary). The client override correctly sets the semantic token, but the component ignores it. Audit for primitive-token leakage in component source; replace with semantic references.
Frequently Asked Questions
What happens when a client submits a color that passes schema validation but the derived tints fail contrast?
Derived tints — computed automatically from brandPrimary via an OKLCH lightness algorithm — are not themselves client-overridable, but they do appear in components as background colors with text painted on them. If the base brandPrimary has unusual lightness, the derived tints can put text at insufficient contrast even when the base color itself audits cleanly.
The solution is to include derived tint values in the contrast audit, not just the raw override color. Compute the tints as part of step 4 of the pipeline, add them temporarily to the token map, and run the full semantic-graph audit against the expanded set. Fail the build if any derived tint creates a contrast violation, even if the source color was technically valid in isolation.
How do we version the override schema without breaking existing clients?
Use the $meta.schemaVersion field to let the pipeline dispatch to the correct schema version. Keep old schema versions active until all clients on that version have migrated. Publish a changelog with every schema update and a migration script that transforms v1 files to v2 format automatically. The theme contract versioning pattern covers this in more depth, including how to handle deprecation windows and enforced cutoff dates.
Can a client override a token for only part of the UI — for example, just the header?
The current schema is global within the tenant scope: [data-tenant="acme-corp"] applies everywhere. Component-scoped overrides (e.g., only the header uses a client’s secondary brand color) require the client to expose a secondary color token that only specific components consume. Add it to the schema as, say, color.brandAccent, then reference --ds-color-brand-accent only in the components the client is licensed to customize. Do not expose raw CSS selectors to clients; that immediately voids the safety guarantees the schema provides.
What if a client’s legal team wants the platform’s base font replaced entirely?
Override --ds-font-family-display for headings — that is already in the open schema. Body copy is typically locked because type-scale fluid sizing depends on the base font’s metrics for accurate ch-unit calculations. If a client must change body font, gate it behind a contract clause that includes a visual QA sign-off from your design team, and add a post-compile step that runs a readability check (minimum font size in the compiled output, line-height ratio). Never silently accept arbitrary font-family strings on locked tokens.
Related
- Multi-Brand & White-Label Token Architecture — parent overview covering brand theme layering, per-tenant runtime theming, and the full token override pipeline
- Overriding Base Tokens for White-Label Clients — step-by-step mechanics for applying client overrides at the token level
- Theme Contract Versioning — how to version the override schema and manage migration windows across clients
- JSON Schema Validation for Design Tokens — deeper coverage of AJV setup,
$defsreuse, and CI integration for token schema enforcement