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.
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.
Step 3 — Flatten everything except the reachable links Link to this section
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.
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.
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.
Related Link to this section
- Token Aliasing & Reference Resolution — the parent area, including when references are load-bearing
- Resolving Token Alias Chains Without Circular References — the resolver that produces the chains flattened here
- Runtime Theme Switching — the mechanism that full flattening silently disables