Handling Multi-Brand Export Conflicts in Token Sync
Part of Design-to-Code Sync Workflows. This guide walks through the exact steps for syncing design tokens when multiple brands share a common core set — resolving value collisions, preventing shared-core edits from clobbering brand-specific overrides, and running a deterministic N-brand build matrix in CI.
Prerequisites
- Style Dictionary v4+ — supports multiple sources and the
include/sourcelayering model. - Tokens Studio for Figma — exporting a
$themesmatrix that includes acoreset plus one set per brand. - Node.js 20+ — required by Style Dictionary v4’s ESM-first config.
- CI provider with matrix job support — GitHub Actions, GitLab CI, or Bitbucket Pipelines.
- A Git-backed
tokens/directory with the following shape already established:tokens/ core/ ← primitive + semantic tokens shared by all brands brand-a/ ← brand-specific overrides only brand-b/ brand-c/ $themes.json ← Tokens Studio theme configuration - Familiarity with Design-to-Code Sync Workflows at a basic level — this guide assumes you already have a single-brand export working.
Step-by-Step Implementation
Step 1 — Structure your token sets with an explicit shared core
The root of every multi-brand conflict is ambiguity about which set owns a given token. Fix this by making the split architectural rather than naming-convention-based. Create a core/ directory that holds every primitive (color.raw, spacing.scale, typography.family) and every semantic alias that is identical across all brands. Brand directories hold only the tokens that differ:
// tokens/core/color.semantic.json
{
"color": {
"action": {
"primary": { "$value": "{color.raw.blue.600}", "$type": "color" },
"primary-hover": { "$value": "{color.raw.blue.700}", "$type": "color" }
},
"surface": {
"default": { "$value": "{color.raw.neutral.0}", "$type": "color" }
}
}
}
// tokens/brand-a/color.semantic.json
{
"color": {
"action": {
"primary": { "$value": "{color.raw.violet.600}", "$type": "color" },
"primary-hover": { "$value": "{color.raw.violet.700}", "$type": "color" }
}
}
}
Brand A only re-declares color.action.primary and color.action.primary-hover. Every other semantic token falls through to core/. Why this works: shallow override files make diff review fast — a reviewer can see at a glance what Brand A actually diverges on, and git diff tokens/brand-a/ never contains noise from unrelated core changes.
Step 2 — Author a deterministic $themes.json matrix
Tokens Studio’s $themes.json controls which token sets are active and in what order. Order matters: later sets overwrite earlier ones for the same token path. Place core first, then the brand override last, so brand values always win:
// tokens/$themes.json
[
{
"id": "brand-a",
"name": "Brand A",
"selectedTokenSets": {
"core/color.primitive": "enabled",
"core/color.semantic": "enabled",
"core/spacing": "enabled",
"core/typography": "enabled",
"brand-a/color.semantic": "enabled"
}
},
{
"id": "brand-b",
"name": "Brand B",
"selectedTokenSets": {
"core/color.primitive": "enabled",
"core/color.semantic": "enabled",
"core/spacing": "enabled",
"core/typography": "enabled",
"brand-b/color.semantic": "enabled"
}
},
{
"id": "brand-c",
"name": "Brand C",
"selectedTokenSets": {
"core/color.primitive": "enabled",
"core/color.semantic": "enabled",
"core/spacing": "enabled",
"core/typography": "enabled",
"brand-c/color.semantic": "enabled"
}
}
]
Why this works: every theme’s set list is explicit and self-contained. Adding a fourth brand is a new entry in this file — not a change to any existing entry — so diffs stay narrow and merge conflicts in $themes.json almost never happen.
Step 3 — Configure Style Dictionary with a per-brand source merge
Rather than one Style Dictionary config, generate one config object per brand at build time. A factory function accepts a brand name and returns the correct include/source array. include loads core tokens as the base layer; source loads brand overrides and wins on collision:
// build/sd.config.mjs
import StyleDictionary from 'style-dictionary';
function brandConfig(brand) {
return {
include: [
'tokens/core/**/*.json'
],
source: [
`tokens/${brand}/**/*.json`
],
platforms: {
css: {
transformGroup: 'css',
buildPath: `dist/${brand}/`,
files: [
{
destination: 'tokens.css',
format: 'css/variables',
options: { outputReferences: true }
}
]
}
}
};
}
const BRANDS = ['brand-a', 'brand-b', 'brand-c'];
for (const brand of BRANDS) {
const sd = new StyleDictionary(brandConfig(brand));
await sd.buildAllPlatforms();
}
Why this works: Style Dictionary resolves aliases after merging include + source, so a brand override that references a core primitive ({color.raw.violet.600}) resolves correctly even though the primitive lives in include, not source.
Step 4 — Lock merge order in a shared utility to prevent future regressions
Hard-coding the merge order inside a build script is fragile once multiple engineers touch the repo. Extract it into a typed constant that both the Style Dictionary config and the CI matrix import:
// tokens/brands.mjs
export const BRANDS = /** @type {const} */ ([
'brand-a',
'brand-b',
'brand-c',
]);
/**
* Returns the Style Dictionary source arrays for a brand.
* Core is always in `include` (lower priority); brand files are in `source` (higher priority).
* Never reverse this order.
*/
export function brandSources(brand) {
return {
include: ['tokens/core/**/*.json'],
source: [`tokens/${brand}/**/*.json`],
};
}
Why this works: a single authoritative list means the CI matrix and the local build script cannot diverge. When you add Brand D, you update one file and every consumer adapts automatically.
Step 5 — Add a pre-build collision audit
Before Style Dictionary runs, scan for token paths declared in more than one brand file that are also in core. These are intentional overrides — but you want to know about them explicitly rather than discovering a missing override at 2 a.m. A Node.js script using glob and deepmerge catches this in under two seconds:
// scripts/audit-collisions.mjs
import { glob } from 'glob';
import { readFileSync } from 'node:fs';
import { BRANDS } from '../tokens/brands.mjs';
function flatten(obj, prefix = '') {
return Object.entries(obj).flatMap(([k, v]) => {
const key = prefix ? `${prefix}.${k}` : k;
return v && typeof v === 'object' && !('$value' in v)
? flatten(v, key)
: [[key, v.$value ?? v]];
});
}
function loadPaths(pattern) {
return glob.sync(pattern).flatMap(file => {
const raw = JSON.parse(readFileSync(file, 'utf8'));
return flatten(raw);
});
}
const corePaths = new Set(loadPaths('tokens/core/**/*.json').map(([k]) => k));
for (const brand of BRANDS) {
const overrides = loadPaths(`tokens/${brand}/**/*.json`).map(([k]) => k);
const collisions = overrides.filter(k => corePaths.has(k));
if (collisions.length > 0) {
console.log(`[${brand}] overrides ${collisions.length} core token(s):`);
collisions.forEach(k => console.log(` ${k}`));
}
}
Run this as a pre-build step. A non-zero override count is not an error — it is expected — but the output surfaces unintentional collisions introduced by a core edit that silently shadowed a brand value. Why this works: making the override surface visible turns a latent drift bug into an observable, reviewable artifact.
Step 6 — Build all brands in a CI matrix
GitHub Actions supports a matrix strategy that fans one job out per brand in parallel. Feed it the brand list as a JSON array and each job builds one brand independently:
# .github/workflows/token-build.yml
name: Token Build
on:
push:
paths:
- 'tokens/**'
jobs:
build-tokens:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
brand: [brand-a, brand-b, brand-c]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Audit token collisions
run: node scripts/audit-collisions.mjs
- name: Build tokens for ${{ matrix.brand }}
run: node build/sd.config.mjs
env:
BUILD_BRAND: ${{ matrix.brand }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: tokens-${{ matrix.brand }}
path: dist/${{ matrix.brand }}/
Note the fail-fast: false flag. Without it, a failure in Brand B’s build cancels Brand A and Brand C jobs before their artifacts upload, which makes it impossible to determine whether the failure was brand-specific. Why this works: parallel, independent brand jobs cut total build time to the slowest single brand, while fail-fast: false ensures you always get a complete picture of which brands are broken.
Step 7 — Isolate changed-brand diffs in CI output
After the matrix runs, collect all brand CSS artifacts and diff them against the previous commit’s artifacts. A Node.js diff script highlights which brand’s output changed and by how many lines — making PR review precise rather than requiring a human to compare four CSS files by eye:
// scripts/diff-brands.mjs
import { execSync } from 'node:child_process';
import { BRANDS } from '../tokens/brands.mjs';
for (const brand of BRANDS) {
const file = `dist/${brand}/tokens.css`;
try {
const result = execSync(
`git diff HEAD~1 -- ${file}`,
{ encoding: 'utf8' }
);
const additions = (result.match(/^\+[^+]/gm) ?? []).length;
const deletions = (result.match(/^-[^-]/gm) ?? []).length;
if (additions + deletions > 0) {
console.log(`[${brand}] +${additions} -${deletions} lines changed`);
} else {
console.log(`[${brand}] no changes`);
}
} catch {
console.log(`[${brand}] no previous artifact to compare`);
}
}
Post this output as a PR comment via the GitHub Actions gh CLI to give reviewers a one-line brand impact summary alongside the build status. Why this works: a token change that was intended for Brand A but accidentally modified Brand B’s output surfaces immediately in review, before it ever reaches staging.
Verification
After the full matrix build completes:
-
All N brands build without errors. Every job in the GitHub Actions matrix should show green. Examine each job’s log for
[brand-*] overrides N core token(s)output from the collision audit — confirm the number matches your expectation. -
Diff isolation is brand-specific. Run
node scripts/diff-brands.mjslocally after editingtokens/brand-a/color.semantic.json. The output should show non-zero changes forbrand-aandno changesforbrand-bandbrand-c. If Brand B or Brand C shows changes after a Brand A-only edit, a token path has leaked across the boundary — re-audit the$themes.jsonset membership. -
CSS artifact spot-check. Open
dist/brand-a/tokens.cssanddist/brand-b/tokens.cssside by side. Tokens incore/should resolve identically; tokens in the respectivebrand-*/override files should differ. A quickgrepfor--color-action-primaryin both files confirms the override is in effect:grep -- '--color-action-primary' dist/brand-a/tokens.css dist/brand-b/tokens.css -
Regression gate. Add a jest or vitest snapshot test that captures the full CSS output for each brand. On the next build, any unexpected change to a brand’s output fails the snapshot and must be explicitly updated — closing the gap between “compiles without error” and “produces the correct values”.
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
| Brand override is silently ignored; core value appears in output | Brand file is in include instead of source in the Style Dictionary config |
Move brand-specific globs to source; include is lower-priority and loses on collision |
$themes.json merge conflicts on every PR |
Multiple engineers editing set membership concurrently | Lock $themes.json to a single responsible owner via CODEOWNERS; use a script to add/remove brands rather than hand-editing |
| A shared-core edit clobbers an expected brand override | Core token was renamed — old alias in brand file now resolves to undefined | Run the collision audit script before and after core renames; update brand alias references to match the new core path |
| CI matrix builds only one brand instead of all three | BUILD_BRAND env var consumed by build script short-circuits iteration over all brands |
The matrix build script must ignore BUILD_BRAND or the factory loop must not use it — pass brand as a CLI argument instead: node build/sd.config.mjs brand-a |
outputReferences: true causes broken var references in brand CSS |
An alias chain crosses the include/source boundary and Style Dictionary cannot resolve the reference path in output |
Set outputReferences: false for the brand platform, or ensure all referenced primitives are in core/ so they appear in both include and source resolution scope |
Migration Note
If your repository currently uses a single flat tokens.json with brand values baked in as top-level keys (e.g., brand-a.color.action.primary), migrate in three phases to avoid breaking your existing pipeline:
Phase 1 — Extract without breaking. Add the tokens/core/ and tokens/brand-*/ directory structure alongside the existing flat file. Update the Style Dictionary config to build from both sources, using the new directories for one brand only while the rest still read from the flat file. This lets you validate the new structure against a known-good reference.
Phase 2 — Migrate brands one at a time. Move each brand’s tokens into its tokens/brand-*/ directory, confirm the CI matrix produces a byte-for-byte identical CSS artifact (using the diff script from Step 7 against the flat-file output), and remove that brand’s entries from the flat file.
Phase 3 — Delete the flat file. Once all brands pass their snapshot regression tests from the new structure, remove tokens.json and update the $themes.json to reference only directory-scoped sets. This phase is a one-line CI config change and a file deletion — no token values change.
Explore the brand theme layering strategies documentation for patterns that extend this structure to runtime theme switching, where the merge order established here becomes the cascade order for CSS custom property inheritance.
Related
- Design-to-Code Sync Workflows — parent workflow covering the full Figma-to-CSS pipeline
- Resolving Figma Tokens Plugin Export Conflicts — sibling guide for single-brand export collision patterns
- Brand Theme Layering Strategies — how the core/override split maps to CSS cascade layers at runtime