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.

Multi-brand token merge topology A shared core token set fans out through per-brand override layers and a deterministic merge step to produce one compiled CSS artifact per brand. Shared Core tokens/core/*.json Brand A tokens/brand-a/*.json Brand B tokens/brand-b/*.json Brand C tokens/brand-c/*.json Merge deterministic brand-a.css brand-b.css brand-c.css
Shared core token set fans through per-brand override layers into a deterministic merge step, producing one CSS artifact per brand.

Prerequisites

  • Style Dictionary v4+ — supports multiple sources and the include/source layering model.
  • Tokens Studio for Figma — exporting a $themes matrix that includes a core set 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:

  1. 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.

  2. Diff isolation is brand-specific. Run node scripts/diff-brands.mjs locally after editing tokens/brand-a/color.semantic.json. The output should show non-zero changes for brand-a and no changes for brand-b and brand-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.json set membership.

  3. CSS artifact spot-check. Open dist/brand-a/tokens.css and dist/brand-b/tokens.css side by side. Tokens in core/ should resolve identically; tokens in the respective brand-*/ override files should differ. A quick grep for --color-action-primary in both files confirms the override is in effect:

    grep -- '--color-action-primary' dist/brand-a/tokens.css dist/brand-b/tokens.css
  4. 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.