Mapping Figma Variable Modes to Theme Selectors

Part of Design-to-Code Sync Workflows. Modes are how a design tool expresses “the same token, a different value in a different context”. CSS expresses the same idea with selectors. The mapping between them is where most sync pipelines quietly go wrong.

Design tool modes mapped onto CSS selectors A colour collection with light and dark modes and a density collection with compact and comfortable modes, each mode mapped to a specific CSS selector in the generated output. Colour collection · mode "Light" :root Colour collection · mode "Dark" [data-theme="dark"] Density collection · "Compact" [data-density="compact"] Brand collection · "Acme" [data-brand="acme"]
Each collection maps to one CSS axis. Collapsing several collections into one attribute is where the mapping stops being expressible.

Prerequisites Link to this section

  • A sync pipeline that can read variable collections and their modes, as built in automating Figma to CSS variable sync.
  • Agreement on which CSS attribute each collection corresponds to.
  • A default mode nominated per collection, since one of them has to become the :root values.
  • The compiled theme files under version control, so a mapping change produces a reviewable diff.

Step-by-Step Implementation Link to this section

Step 1 — Declare the map explicitly Link to this section

Intent: never infer a selector from a mode name; state the mapping in configuration.

// sync/mode-map.mjs
export const MODE_MAP = {
  'Colour': {
    attribute: null,                    // the default collection lives on :root
    default: 'Light',
    modes: {
      'Light': ':root',
      'Dark':  '[data-theme="dark"]',
    },
  },
  'Density': {
    attribute: 'data-density',
    default: 'Comfortable',
    modes: {
      'Comfortable': ':root',
      'Compact':     '[data-density="compact"]',
    },
  },
};

Why this works: a designer renaming a mode from “Dark” to “Dark mode” would silently break an inferred mapping and produce a theme file with no selector, or worse, a selector nobody expected. With an explicit map the rename produces a loud failure naming the mode that is no longer mapped, which is a two-second fix rather than an investigation.

Step 2 — Emit the default mode to :root Link to this section

Intent: one mode per collection becomes the base, and the others become overrides of it.

export function emit(collection, modeValues, map) {
  const spec = map[collection];
  const blocks = [];

  for (const [mode, tokens] of Object.entries(modeValues)) {
    const selector = spec.modes[mode];
    if (!selector) throw new Error(`Mode "${mode}" in "${collection}" has no selector mapping`);
    const decls = Object.entries(tokens)
      .map(([name, value]) => `  ${name}: ${value};`)
      .join('\n');
    blocks.push(`${selector} {\n${decls}\n}`);
  }
  return blocks.join('\n\n');
}

Why this works: throwing on an unmapped mode is the whole point of the explicit map. The alternative — skipping unmapped modes — produces output that compiles, deploys, and is missing a theme that designers can see in the design file and nobody can find in the product.

Step 3 — Emit only the differences for non-default modes Link to this section

Intent: a dark theme block should contain the tokens that differ, not a full copy of every token.

export function diffAgainstDefault(defaultTokens, modeTokens) {
  const out = {};
  for (const [name, value] of Object.entries(modeTokens)) {
    if (defaultTokens[name] !== value) out[name] = value;
  }
  return out;
}

Why this works: a full copy works and hides information. When every token appears in both blocks, a reviewer cannot see which values the dark theme actually changes, and the theme parity check cannot distinguish “deliberately the same” from “copied and never revisited”. Emitting only differences makes the dark block a statement about what dark mode is.

Step 4 — Handle mode combinations honestly Link to this section

Intent: two collections produce a matrix, and CSS handles it through selector combination — but only if the selectors are independent.

:root                        { --ds-color-surface: #ffffff; --ds-space-inset: 16px; }
[data-theme="dark"]          { --ds-color-surface: #131c2e; }
[data-density="compact"]     { --ds-space-inset: 12px; }

/* The combination needs no extra block: the two attributes are independent. */

Why this works: because each collection maps to a distinct attribute setting distinct tokens, the four combinations are produced by the cascade with no explicit combination block. This only holds while the collections are genuinely orthogonal. The moment a token needs a different value specifically in dark-and-compact, the matrix has to be enumerated — and that is a signal the collections are not independent and probably should not be modelled as separate axes.

Orthogonal collections against entangled ones Two collections that set disjoint token sets produce four combinations from three CSS blocks, while collections that both set the same token require an explicit block per combination. Orthogonal theme sets colour tokens density sets spacing tokens no token appears in both 4 combinations, 3 CSS blocks the cascade does the rest Entangled both collections set surface order decides the winner the combination is undefined 4 combinations, 4 blocks and it grows multiplicatively
Orthogonality is the property that keeps the output linear in the number of modes. Losing it is how a two-collection system becomes an eight-block one.

Step 5 — Fail when a mode loses its mapping Link to this section

Intent: a rename in the design tool must break the build, not the product.

// scripts/check-mode-map.mjs
import { MODE_MAP } from '../sync/mode-map.mjs';
import { readDesignCollections } from '../sync/design.mjs';

const live = await readDesignCollections();
let failed = 0;

for (const [name, collection] of Object.entries(live)) {
  const spec = MODE_MAP[name];
  if (!spec) { console.error(`Collection "${name}" has no mapping`); failed++; continue; }

  for (const mode of collection.modes) {
    if (!spec.modes[mode]) { console.error(`"${name}" mode "${mode}" is unmapped`); failed++; }
  }
  for (const mapped of Object.keys(spec.modes)) {
    if (!collection.modes.includes(mapped)) {
      console.error(`"${name}" mapping references missing mode "${mapped}"`); failed++;
    }
  }
}
process.exit(failed ? 1 : 0);

Why this works: the check runs in both directions, which matters because the two failures have different causes. An unmapped mode means a designer added something the pipeline does not know how to emit. A mapping pointing at a mode that no longer exists means a rename or deletion happened upstream. Reporting them separately tells you which conversation to have.

Step 6 — Keep the selector strings out of the token files Link to this section

Intent: the map belongs in the pipeline, not scattered through the source.

Tokens describe values; selectors describe where those values apply. Mixing them — a token file containing "selector": "[data-theme=dark]" — couples the source to one output format and breaks the moment a second platform consumes it. iOS has no selectors, and a token file that assumes them cannot be compiled for it.

Keeping the map in one pipeline module also means changing the theme attribute — from data-theme to data-color-scheme, say — is a one-line change rather than a find-and-replace across the token set.

Verification Link to this section

Run the sync against a design file where you have deliberately renamed a mode and confirm the check fails with that mode named. Then confirm the generated CSS contains exactly one block per mapped mode, that the default mode’s block uses :root, and that the non-default blocks contain fewer declarations than the default — which proves the diffing in step 3 is working.

Finally, load the built page and toggle each attribute independently, confirming the combinations resolve as expected. This is the check that catches entanglement: if setting density changes a colour, two collections are writing the same token and the model needs revisiting.

Three failures a mode mapping check catches Three failure rows: a newly added mode with no mapping, a mapping pointing at a deleted mode, and two collections writing the same token. new mode, no mapping a designer added "High contrast" — the pipeline does not know where to emit it mapping points at a deleted mode a rename upstream; the selector would emit an empty block two collections write one token the collections are not orthogonal; combinations become order-dependent
The first two are caught by comparing the map against the live collections. The third needs a token-level check across the emitted blocks.

Troubleshooting Link to this section

Symptom Likely cause Fix
A theme block is empty Every value in that mode matches the default Correct, if intended; otherwise the mode was never populated in the design file
The dark block repeats every token Diffing against the default is not applied Apply the diff; a full copy hides what the theme actually changes
A new mode silently disappears The pipeline skips unmapped modes Throw on unmapped modes rather than skipping them
Setting density changes colours Two collections write the same token Split the token, or merge the collections — they are not independent axes
The generated selector is wrong after a rename The map was inferred from mode names Use an explicit map and let the check catch renames

Migration Note Link to this section

Systems that started with a single colour collection often acquire a second collection later — density, brand or platform — and the first sync after that addition is where the model gets decided by accident.

Take the decision deliberately at that point. Write the map for both collections, run the orthogonality check, and if the two collections do write overlapping tokens, resolve it before generating anything. Resolving it afterwards means changing tokens that components already consume, which is a much larger change than adjusting a collection in the design file while it is still new.

Modes That Should Not Become Selectors Link to this section

Not every mode in a design file corresponds to something CSS should express, and mapping all of them mechanically produces output nobody consumes.

Design-time modes are the common case. A file may carry a “Redlines” mode that swaps colours for annotation, or a “Print proof” mode used for reviewing artwork. These exist for the design process and have no product counterpart. The map should omit them, and the check from step 5 should distinguish “unmapped” from “deliberately excluded” so their absence does not fail the build every run.

export const MODE_MAP = {
  'Colour': {
    modes: { 'Light': ':root', 'Dark': '[data-theme="dark"]' },
    ignore: ['Redlines', 'Print proof'],
  },
};

Platform modes are the other case worth thinking about. A collection with “Web”, “iOS” and “Android” modes is describing build targets rather than runtime states, and each should produce a separate artefact rather than a selector inside one stylesheet. Emitting them as selectors would ship every platform’s values to every platform, which is both wasteful and confusing to anyone reading the compiled CSS.

The distinction to apply is whether the mode can change while the page is open. Theme and density can; platform and design-time modes cannot. Runtime-variable modes become selectors, build-time modes become separate outputs, and process-only modes are ignored — three destinations, decided once, in the same map.

Naming Modes for the Map Link to this section

One small convention pays for itself: name modes in the design file exactly as the map expects, and keep those names stable even when the design language around them changes. A mode called “Dark” that becomes “Dark v2” during a redesign breaks the mapping for no product reason, and the rename is almost always cosmetic. Version the values, not the mode name.