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.

Token types split into a paint partition and a geometry partition A token manifest divides by type into a paint partition containing colour, shadow and gradient tokens, which may appear in theme blocks, and a geometry partition containing dimension, duration and font tokens, which may not. tokens.json every token carries a $type Paint partition — allowed in a theme color · shadow · gradient · border-color changing these repaints, never re-measures emitted into [data-theme] blocks Geometry partition — forbidden dimension · fontFamily · fontWeight · number changing these invalidates layout for the tree emitted once, into :root only
The partition is derived from data the token file already carries. No new metadata is needed — only a build step that respects it.

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.

Where the partition is enforced along the build Three enforcement points along the pipeline: a compiler filter that drops geometry tokens, a post-build check that fails on violations, and a lint rule that catches hand-written theme blocks in application CSS. compiler filter drops geometry tokens the artefact is safe by construction post-build check reads the compiled CSS names the offending token lint rule catches hand-written [data-theme] blocks in apps The third point is the one teams forget Application repositories write their own theme overrides. A rule in the shared config catches those before they ship.
Three enforcement points, each catching what the previous one cannot see: generated output, compiled output, and hand-written application CSS.

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.

Layout pass count with and without a geometry token in the theme Two rows of measurements across three page sizes. A correctly partitioned theme records zero layout passes at every size, while a theme containing a spacing token records counts that grow with the element count. Layout passes recorded across one theme switch, by page complexity correctly partitioned theme 0 200 elements 0 2000 elements 0 10000 elements one spacing token in the theme block 3 200 elements 9 2000 elements 27 10000 elements The healthy row stays flat as the page grows, which is what makes the assertion safe to gate on.
The healthy result is not merely smaller — it is flat. A partitioned theme costs the same on a ten-thousand-element page as on a small one.
// 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.