Flattening Alias Chains at Build Time

Part of Token Aliasing & Reference Resolution. Flattening replaces every var() hop with the literal it eventually resolves to. It makes the output smaller and the resolution cheaper, and — applied indiscriminately — it silently destroys runtime theming. This guide covers where the line sits.

What flattening removes from a chain A four-link chain shown before and after flattening. Before, each link is a var() reference and a theme can re-point the middle link. After, every consumer holds the literal and re-pointing has no effect. Before — the theme has a place to intervene --btn-bg: var(…) --color-action: var(…) --blue-600: #2563eb theme re-points here ↑ After — every consumer holds the literal --btn-bg: #2563eb --color-action: #2563eb --blue-600: #2563eb nothing to re-point A dark theme setting --color-action now changes nothing: --btn-bg no longer reads it.
Flattening does not merely inline values — it removes the indirection that a theme block relies on to reach downstream consumers.

Prerequisites Link to this section

  • A working resolver that can produce both the chain and the literal for every token, as built in resolving alias chains.
  • A clear list of which output platforms exist — web, iOS, Android, documentation — and which of them support runtime theming.
  • A record of which tokens a theme or brand block actually re-points; the build already has this if themes are compiled from the same source.
  • A size budget for the CSS artefact, if size is the motivation.

Step-by-Step Implementation Link to this section

Step 1 — Decide per platform, not per project Link to this section

Intent: the correct answer differs by output target, and expressing that in configuration prevents the question being re-argued per release.

// tokens.config.mjs
export const platforms = {
  web:      { flatten: 'partial' },   // keep theme-reachable links as var()
  ios:      { flatten: 'full' },      // no cascade — references are meaningless
  android:  { flatten: 'full' },
  docs:     { flatten: 'none' },      // show authors the whole chain
};

Why this works: native platforms have no runtime equivalent of the cascade, so a preserved reference there is either dropped or emulated badly. Documentation wants the opposite: the chain is the thing being documented. Only the web platform has a genuine trade-off, and naming it partial forces the question of which links matter.

Step 2 — Identify the theme-reachable set Link to this section

Intent: a link is load-bearing if any theme or brand block re-points it. Everything else can be collapsed safely.

// scripts/theme-reachable.mjs
import { readFileSync } from 'node:fs';
import { globSync } from 'node:fs';

// Every custom property declared inside a theme or brand block.
export function themeReachable(themeFiles) {
  const names = new Set();
  for (const file of themeFiles) {
    const css = readFileSync(file, 'utf8');
    for (const match of css.matchAll(/^\s*(--[\w-]+)\s*:/gm)) names.add(match[1]);
  }
  return names;
}

Why this works: the set is derived from the theme output rather than declared by hand, so it stays correct as themes gain and lose tokens. Deriving it from the source theme files rather than from the compiled CSS would miss anything a plugin injects.

Intent: collapse the chain up to the last theme-reachable token, and keep a single reference from there.

// scripts/flatten-partial.mjs
export function emit(name, chain, literal, reachable) {
  // chain is ordered from the token itself down to the primitive.
  const hop = chain.slice(1).find((link) => reachable.has(cssName(link)));
  return hop
    ? `${cssName(name)}: var(${cssName(hop)});`   // keep one hop: the theme needs it
    : `${cssName(name)}: ${literal};`;            // safe to inline
}

const cssName = (path) => `--${path.replace(/\./g, '-')}`;

Why this works: a consumer only needs to reference the nearest theme-reachable ancestor, not the whole chain. If --btn-bg → --color-action → --brand-accent → #2563eb and only --color-action is re-pointed by a theme, emitting --btn-bg: var(--color-action) preserves theming while removing two hops of runtime resolution.

Full, partial and no flattening compared Three output strategies compared across four criteria: output size, runtime resolution cost, whether runtime theming survives, and how readable the output is for debugging. criterion full flatten partial none output size smallest near smallest largest runtime theming broken preserved preserved debuggability value only one hop visible full chain
Partial flattening is the only column with no red cell, which is why it is the default for web output in most systems that have thought about it.

Step 4 — Keep the chain in the manifest regardless Link to this section

Intent: flattening the artefact should not discard the information; the manifest is where documentation and audits read it from.

manifest[cssName(name)] = {
  $type: token.$type,
  value: literal,
  chain: chain.map(cssName),
  emitted: hop ? `var(${cssName(hop)})` : literal,
};

Why this works: the manifest is what the documentation generator, the orphan audit and the breaking change detector all consume. Keeping the resolved chain there means flattening is purely an output decision and never loses data.

Step 5 — Measure whether it was worth it Link to this section

Intent: flattening is usually justified on performance grounds; check that the justification survives contact with numbers.

# Compare the two outputs on the metrics that motivated the change.
wc -c dist/tokens.flat.css dist/tokens.ref.css
node scripts/measure-resolution.mjs --file dist/tokens.flat.css
node scripts/measure-resolution.mjs --file dist/tokens.ref.css

Why this works: in practice the size difference between full and partial flattening is small — a few percent of a file that is itself a small fraction of the stylesheet — while the difference between either and no flattening can be substantial on a deep graph. Measuring stops the team from paying the theming cost of full flattening for a saving that turns out to be a rounding error.

Verification Link to this section

Two assertions cover the risk. The first is that theming still works, which is the thing flattening can silently break. The second is that the output actually got smaller, which is the thing it was supposed to achieve.

// tests/flatten.spec.mjs
import { test, expect } from '@playwright/test';

test('theme still re-points every reachable token', async ({ page }) => {
  await page.goto('/');

  const light = await page.evaluate(() =>
    getComputedStyle(document.documentElement).getPropertyValue('--btn-bg').trim());

  await page.getByRole('button', { name: /dark theme/i }).click();

  const dark = await page.evaluate(() =>
    getComputedStyle(document.documentElement).getPropertyValue('--btn-bg').trim());

  // If flattening removed the link, these would be identical.
  expect(dark).not.toBe(light);
});

Run the same assertion for every token that a theme block declares. Generating those assertions from the theme-reachable set means the test grows automatically as themes gain tokens — which is important, because the failure mode here is a token quietly dropping out of the theme rather than a test breaking.

Deciding whether a link may be flattened A decision tree asking whether a theme re-points the link, whether a brand re-points it, and whether the platform supports the cascade, resolving to flatten or preserve. May this link be flattened? theme declares it? preserve brand declares it? preserve neither? flatten On a platform without a cascade, every branch collapses to "flatten" — the question only arises for CSS output.
The rule is mechanical enough to run in the build, which is the point: no human should be deciding per token whether a reference is load-bearing.

Troubleshooting Link to this section

Symptom Likely cause Fix
The dark theme has no effect on some components Those components’ aliases were fully flattened past the theme-reachable link Rebuild with partial flattening and confirm the reachable set includes the theme’s tokens
Output is barely smaller after flattening The graph is already shallow, or gzip was already collapsing the repetition Measure compressed size, not raw; if the saving is under a percent, prefer no flattening
A brand override stopped working after a build change The brand file was not included when the reachable set was computed Compute the set from all theme and brand outputs, not just the default theme
DevTools shows a literal where a variable was expected Working as designed for a non-reachable link Check the manifest’s chain field to see the relationship that was collapsed
Documentation shows literals instead of relationships The docs platform inherited the web flattening setting Set the docs platform to none; it has different requirements from the shipped CSS

The Cost Model, Stated Plainly Link to this section

It is worth being explicit about what flattening actually saves, because the intuition tends to overestimate it.

Resolving a var() reference is cheap. The engine does it during style computation, once per element that uses the property, and the work is a lookup rather than a parse. A three-hop chain costs three lookups instead of one — measurable in a synthetic benchmark with hundreds of thousands of elements, invisible in an application. The real cost of chains is on the human side: reading, debugging and predicting them.

Output size is a more honest motivation, and even there the numbers are modest. A var(--color-action) reference is longer than #2563eb, so partial flattening of a leaf token can make the file slightly larger rather than smaller. The saving comes from removing intermediate declarations entirely, which only helps when the graph has many tokens that exist purely as pass-through aliases.

Where flattening genuinely pays is in output for platforms that cannot resolve references at all, and in artefacts consumed by tools rather than browsers — a design documentation site, a Figma plugin, a diffing script. Those need values, and giving them references means every consumer reimplements the resolver.

So the honest summary is: flatten for non-CSS consumers because they need it, flatten partially for CSS because it tidies the output, and do not expect a performance result. If a theme switch is slow, the cause is almost certainly in the token partition rather than in the depth of the reference graph.

Migration Note Link to this section

Most systems start with no flattening, because that is what compilers do by default when outputReferences is enabled. Moving to partial flattening is safe and usually worth a few percent; moving to full flattening is a one-way door that breaks theming, and it should only ever be applied to platforms that cannot theme at runtime.

If a codebase is currently fully flattened and theming does not work, the fix is this change rather than a new theme mechanism — a surprisingly common misdiagnosis. The symptom is that the theme block is present in the CSS, the attribute is set on the root, and computed values do not change. Checking whether a component’s custom property holds a var() or a literal answers it in seconds.

One Artefact Per Consumer, Not One Per Repository Link to this section

A last piece of structure worth adopting early: emit a separately named artefact per flattening strategy rather than trying to satisfy every consumer with one file. tokens.css keeps its references, tokens.flat.json is fully resolved for tooling, and tokens.docs.json carries the chains. Consumers pick the one that matches what they can resolve, and no consumer has to reimplement the resolver to get what it needs.