Avoiding Layout Thrash When Swapping Theme Tokens
Part of Theme Transition Motion & Performance. The task here is narrow and mechanical: guarantee that no theme block can ever declare a token whose type forces the browser to re-measure the page, and prove it in the build rather than trusting review.
Prerequisites Link to this section
- A token source in DTCG shape, where every token declares a
$type. If the token files predate that convention, the migration is mechanical and worth doing first. - A compiler — Style Dictionary, Cobalt or an equivalent — whose formats can filter tokens by type. See the token compiler comparison if that choice is still open.
- At least two published themes, so the partition has something to protect.
- A CI job that runs the compiler and can fail the build on a non-zero exit code.
Step-by-Step Implementation Link to this section
Step 1 — Classify every token type Link to this section
Intent: decide, once, which $type values are safe inside a theme block. The answer follows from which CSS properties the token can end up in, not from what the value looks like.
// scripts/partition.mjs
export const PAINT_TYPES = new Set([
'color',
'shadow',
'gradient',
]);
// Everything else is geometry as far as theming is concerned, including
// types that look harmless: a `number` token may end up in line-height,
// and a `duration` token in a transition that gates a layout-affecting property.
export const isPaint = (token) => PAINT_TYPES.has(token.$type);
Why this works: the classification is conservative on purpose. A dimension token is obviously geometry. A number token is less obviously so, but the moment one is used as a line height or a flex grow factor it becomes layout-affecting, and the compiler cannot know which. Starting from a small allow-list and widening deliberately is far safer than starting from a deny-list and discovering the gaps in production.
Step 2 — Emit theme blocks from the paint partition only Link to this section
Intent: make the compiler structurally incapable of producing a theme block that contains geometry.
// sd.config.mjs
import StyleDictionary from 'style-dictionary';
import { isPaint } from './scripts/partition.mjs';
const sd = new StyleDictionary({
source: ['tokens/**/*.json'],
platforms: {
css: {
transformGroup: 'css',
buildPath: 'dist/',
files: [
{
destination: 'tokens.css',
format: 'css/variables',
options: { selector: ':root' },
},
{
destination: 'theme-dark.css',
format: 'css/variables',
filter: (token) => isPaint(token) && token.filePath.includes('/dark/'),
options: { selector: '[data-theme="dark"]' },
},
],
},
},
});
await sd.buildAllPlatforms();
Why this works: the filter runs before the format, so a geometry token placed in the dark theme source directory is silently dropped from the output rather than emitted into the theme block. Silent dropping is not ideal on its own — step 4 turns it into a loud failure — but it means that even if the gate is bypassed, the compiled artefact remains safe.
Step 3 — Give geometry tokens a single home Link to this section
Intent: if a design genuinely needs different spacing in dark mode, that requirement must be expressed as something other than a theme token.
/* Correct: the difference is a component variant, not a theme value. */
:root {
--ds-space-card-inset: 1rem;
}
/* Wrong: this makes every theme switch a relayout of the document. */
[data-theme="dark"] {
--ds-space-card-inset: 1.25rem;
}
/* Right: express the intent where it belongs. */
.card--comfortable {
--ds-space-card-inset: 1.25rem;
}
Why this works: the requirement “cards need more breathing room on dark backgrounds” is a design decision about density, not about theme. Expressing it as a variant makes it available in both themes, testable in isolation, and — the point here — decoupled from the theme switch. In the small number of cases where the difference genuinely is theme-specific, apply the variant class alongside the theme rather than folding it into the token.
Step 4 — Fail the build when the partition is violated Link to this section
Intent: turn the silent filter into an explicit, actionable error.
// scripts/check-theme-partition.mjs
import { readFileSync } from 'node:fs';
import { isPaint } from './partition.mjs';
const manifest = JSON.parse(readFileSync('dist/token-manifest.json', 'utf8'));
const themeCss = readFileSync(process.argv[2] ?? 'dist/theme-dark.css', 'utf8');
const declared = [...themeCss.matchAll(/^\s*(--[\w-]+)\s*:/gm)].map((m) => m[1]);
const violations = declared.filter((name) => {
const token = manifest[name];
if (!token) return true; // unknown token in a theme block
return !isPaint(token);
});
if (violations.length) {
console.error('Theme block contains non-paint tokens:');
for (const name of violations) {
const t = manifest[name];
console.error(` ${name} — $type: ${t ? t.$type : 'unknown'}`);
}
console.error('\nA theme block may only declare color, shadow and gradient tokens.');
console.error('Changing anything else forces a layout pass on every theme switch.');
process.exit(1);
}
console.log(`theme partition OK — ${declared.length} paint token(s)`);
Why this works: the check runs against the compiled CSS rather than the source, so it also catches tokens injected by a plugin, hand-written theme overrides, and anything a future compiler upgrade changes about the output. It names the offending token and its type, which makes the failure fixable without reading the script.
Step 5 — Extend the rule to application stylesheets Link to this section
Intent: product repositories inevitably add their own theme overrides. The same constraint has to reach them.
// stylelint-plugin-theme-partition/index.js
const stylelint = require('stylelint');
const ruleName = 'ds/theme-block-paint-only';
const messages = stylelint.utils.ruleMessages(ruleName, {
rejected: (prop) =>
`"${prop}" is a geometry token and must not be set inside a theme block — ` +
`it forces a full relayout on every theme switch. Use a component variant instead.`,
});
module.exports = stylelint.createPlugin(ruleName, (manifestPath) => {
const manifest = require(manifestPath);
return (root, result) => {
root.walkRules(/\[data-theme/, (rule) => {
rule.walkDecls(/^--/, (decl) => {
const token = manifest[decl.prop];
if (token && !['color', 'shadow', 'gradient'].includes(token.$type)) {
stylelint.utils.report({
ruleName, result, node: decl,
message: messages.rejected(decl.prop),
word: decl.prop,
});
}
});
});
};
});
Why this works: it reuses the published manifest rather than hard-coding a list, so the rule stays correct as tokens are added. The message explains the consequence and names the alternative, which is what determines whether a rule gets fixed or disabled — the pattern discussed at length in writing custom Stylelint rules.
Verification Link to this section
The partition check proves the tokens are correct. A browser check proves the consequence.
// tests/theme-layout.spec.mjs
import { test, expect } from '@playwright/test';
test('theme switch does not trigger layout', async ({ page }) => {
await page.goto('/');
const client = await page.context().newCDPSession(page);
await client.send('Performance.enable');
const before = await client.send('Performance.getMetrics');
const layoutBefore = before.metrics.find((m) => m.name === 'LayoutCount').value;
await page.getByRole('button', { name: /dark theme/i }).click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
const after = await client.send('Performance.getMetrics');
const layoutAfter = after.metrics.find((m) => m.name === 'LayoutCount').value;
// A correctly partitioned theme swap adds no layout passes at all.
expect(layoutAfter - layoutBefore).toBeLessThanOrEqual(1);
});
The assertion allows one layout pass rather than zero because scrollbar changes and font loading can legitimately produce one during the same window. A correctly partitioned theme produces zero or one; a theme carrying a spacing token produces several, and the number scales with page complexity, so the test fails more decisively on exactly the pages where it matters most.
Troubleshooting Link to this section
| Symptom | Likely cause | Fix |
|---|---|---|
| The check passes but the switch still relayouts | A component reads a theme token inside a calc() for a geometry property |
Search for calc( expressions referencing theme tokens; move the calculation to a non-theme token |
| The check reports “unknown token” for a valid property | The manifest was built from a different source than the CSS | Generate the manifest in the same compiler run that emits the theme file |
| The lint rule fires on a legitimate override | An application genuinely needs a per-theme shadow that the manifest classifies as a dimension | Fix the token’s $type at the source — a shadow token typed as dimension is mis-declared |
| Layout count is high but the theme is correct | Web fonts loading during the measurement window | Wait for document.fonts.ready before taking the first metrics sample |
| A brand switch relayouts while the theme switch does not | The brand block was not built through the same filter | Apply the same partition filter to every generated block, not only the theme ones |
Migration Note Link to this section
In an existing system the partition is almost always already true and simply unenforced — most theme blocks contain nothing but colours. The migration is therefore usually a matter of discovering the two or three exceptions rather than restructuring anything.
Start by running the check in reporting mode against the current build. If it reports nothing, add it as a blocking gate immediately; the constraint is already satisfied and the gate is free. If it reports a handful of tokens, each one is a small design conversation: what was the intent behind the difference, and can it be expressed as a variant instead.
The one case that genuinely resists conversion is a border width that differs between themes, usually because a border carries more of the visual separation in dark mode where shadows are weak. The cleanest resolution is to keep the width fixed and vary only the border colour, using a stronger contrast step in dark mode — which lands the change back in the paint partition and looks better than the width change did.
Related Link to this section
- Theme Transition Motion & Performance — the parent area and the cost model behind this constraint
- Measuring the Paint Cost of Custom Property Updates — turning the browser measurement above into a stable CI signal
- Elevation & Shadow Tokens — the token family most often mis-typed, and the one most likely to trip this gate