Sharing Design Tokens Across Micro-Frontends #

When each remote bakes colour and spacing values into its own build, a brand change requires every team to deploy — this page publishes tokens as runtime custom properties so the same change is one deploy of one artefact.

The mechanism is small; the discipline around it is what makes it hold. Tokens have to be defined in exactly one place, consumed everywhere, enforced by lint, and versioned like any other contract. It implements the token layer of design system and theming across remotes.

Build-time versus runtime design tokens Moving token values from each remote's build to a runtime stylesheet turns a coordinated multi-team release into a single deploy. Tokens baked into each build Each remote compiles its own values A rebrand needs every team to deploy Upgrade order decides what users see Drift is invisible until composition Tokens read at runtime One artefact defines every value A rebrand is one deploy Every remote updates at once Drift is impossible by construction
The property worth protecting is that changing a value never requires a consumer to rebuild.

Prerequisites #

Step 1 — Author tokens once, in a neutral format #

Author in JSON rather than CSS, so the same source can emit CSS custom properties for the web, and other formats if the organisation ever needs them.

// tokens/color.json
{
  "color": {
    "surface":  { "value": "#ffffff", "darkValue": "#11283a" },
    "text":     { "value": "#10324d", "darkValue": "#e8f2fb" },
    "accent":   { "value": "#0f77c7", "darkValue": "#7cc2ff" },
    "danger":   { "value": "#f98d4a", "darkValue": "#f98d4a" }
  }
}

Keeping the light and dark value together is deliberate: it makes a missing dark value a visible gap in the source rather than something discovered when the page is half-legible.

// style-dictionary.config.js — emit a single CSS file with both themes
export default {
  source: ['tokens/**/*.json'],
  platforms: {
    css: {
      transformGroup: 'css',
      buildPath: 'dist/',
      files: [{ destination: 'tokens.css', format: 'css/variables-themed' }],
    },
  },
};

Step 2 — Publish tokens as a federated remote, not only a package #

From token source to applied stylesheet One source emits both the runtime stylesheet the shell applies and the package remotes use for standalone development. One source, two distribution channels Author JSON light + dark together Build CSS custom properties Publish remote and package Shell applies prepended to head
Publishing both forms is what lets a remote run offline without letting it drift in composition.

A package means each remote pins a version and upgrades when it chooses, which is exactly the drift you are trying to remove. A federated remote means one version is resolved at runtime for the whole page.

// tokens/webpack.config.js
new ModuleFederationPlugin({
  name: 'tokens',
  filename: 'remoteEntry.js',
  exposes: {
    // The stylesheet, and a tiny helper for JavaScript that needs a value.
    './styles': './src/apply-tokens.js',
    './read': './src/read-token.js',
  },
});
// tokens/src/apply-tokens.js — injected once by the shell, before remotes mount
import cssText from '../dist/tokens.css?raw';

export function applyTokens() {
  if (document.getElementById('acme-tokens')) return;
  const style = document.createElement('style');
  style.id = 'acme-tokens';
  style.textContent = cssText;
  // First in <head>, so any remote stylesheet can still override its own scope.
  document.head.prepend(style);
}

Prepending rather than appending matters. Tokens should be the lowest-specificity layer on the page, so a remote’s own scoped rules win within their scope without a specificity fight.

Publish the package too. Remotes need the values at build time for their standalone dev servers, and a design system that only exists at runtime cannot be worked on offline.

Step 3 — Apply tokens before the first remote mounts #

A remote that mounts before tokens are applied renders one frame with its fallback values, which reads as a flash of unstyled content.

// host/src/boot.js
import { applyTokens } from 'tokens/styles';

applyTokens();                    // synchronous, before anything else
await loadRemote('header', './Header');

If the tokens remote is fetched over the network, preload its entry in the document head so it is not discovered late — the same reasoning as preloading and prefetching remote entry files. Tokens are small, on the critical path, and change rarely, which makes them close to the ideal preload candidate.

Step 4 — Consume tokens with sensible fallbacks #

Every var() in a remote should carry a fallback, so the remote renders correctly in its own dev server where no shell has applied anything.

/* remote/src/Cart.module.css */
.root {
  background: var(--color-surface, #ffffff);
  color: var(--color-text, #10324d);
  padding: var(--space-3, 0.75rem);
  border-radius: var(--radius-md, 10px);
}

Use the light-theme value as the fallback, never an arbitrary one. A remote that looks wrong standalone gets “fixed” by a developer adding a hard-coded value, and that hard-coded value then survives into composition.

Generate the fallbacks rather than typing them, so they cannot drift:

// A build step that rewrites var(--x) to var(--x, <default>) from the token source
const defaults = JSON.parse(fs.readFileSync('node_modules/@acme/tokens/dist/defaults.json'));
css = css.replace(/var\((--[\w-]+)\)/g, (m, name) =>
  defaults[name] ? `var(${name}, ${defaults[name]})` : m);

Step 5 — Enforce the rules in every remote’s build #

The two lint rules that prevent drift A naming-pattern rule and a disallowed-value rule together draw the line between a remote's private values and the shared contract. Case Allowed Rejected by lint Colour values in a remote var(--color-accent) #0f77c7, rgb(), hsl() Custom properties in a remote --cart-row-height --color-accent Spacing in a remote var(--space-3) 0.75rem literal Token definition only in @acme/tokens anywhere else
Ship the configuration from the token package itself — a rule each team copies is a rule that diverges.

Two lint rules remove almost all drift, and both are cheap.

// stylelint.config.js — shipped from @acme/tokens, extended by every remote
module.exports = {
  rules: {
    // 1. No raw colour values outside the token package itself.
    'color-no-hex': true,
    'declaration-property-value-disallowed-list': {
      '/^(color|background|border)/': ['/^rgb/', '/^hsl/', '/^#/'],
    },
    // 2. No remote may define a shared token.
    'custom-property-pattern': '^(?!(color|space|radius|font|shadow)-)[a-z][a-z0-9-]*$',
  },
};

The second rule is the important one. It permits a remote to declare --cart-row-height and rejects --color-accent, which is precisely the line between a private value and a shared contract.

Ship the configuration from the token package so every remote extends one file. A rule each team copies is a rule that diverges.

Step 6 — Give JavaScript a way to read a token #

Some code genuinely needs a value rather than a style — a canvas chart, a library configured with a hex string, a meta theme-colour tag.

// tokens/src/read-token.js — read live, never cache across a theme change
export function readToken(name, fallback = '') {
  const value = getComputedStyle(document.documentElement)
    .getPropertyValue(name).trim();
  return value || fallback;
}

The absence of caching is the point. A cached value is correct until the theme changes, at which point that one consumer is wrong for the rest of the session — the single most common cause of one component staying light on a dark page.

For consumers that must react to changes, subscribe to the attribute rather than polling:

export function onThemeChange(callback) {
  const observer = new MutationObserver(() => callback());
  observer.observe(document.documentElement, {
    attributes: true, attributeFilter: ['data-theme'],
  });
  return () => observer.disconnect();
}

Step 7 — Version token names as a contract #

Values may change freely; names may not. Renaming a token breaks every remote that reads it, with no build-time error anywhere — the property simply resolves to its fallback.

/* One release: new name added, old name aliased to it. */
:root {
  --color-accent: #0f77c7;
  --color-primary: var(--color-accent);  /* deprecated in 3.0, removed in 4.0 */
}

Because a missing token fails silently, add a development-mode audit that reports any var(--…) on the page resolving to nothing:

// dev only: find tokens that resolve to an empty string
export function auditTokens(names) {
  const style = getComputedStyle(document.documentElement);
  const missing = names.filter((n) => !style.getPropertyValue(n).trim());
  if (missing.length) console.warn('[tokens] unresolved:', missing.join(', '));
}

Verification #

Troubleshooting #

Symptom: a remote renders with fallback colours in the composed page.

Diagnosis: it mounted before tokens were applied, or its styles are in a shadow root created before the token element existed. Fix: apply tokens synchronously in the shell’s boot, before loading any remote.

Symptom: one remote ignores the brand change.

Diagnosis: it copied values into JavaScript or into its own build. Fix: replace the copies with var(), and add the token lint to that remote’s pipeline.

Symptom: token values are correct but spacing is inconsistent.

Diagnosis: remotes consume colour tokens and hard-code spacing, which lint rules focused on colour do not catch. Fix: extend the disallowed-value rule to spacing and radius properties.

Symptom: a remote’s styles lose to the token stylesheet.

Diagnosis: tokens were appended rather than prepended and now win on source order. Fix: prepend the token element so it is the lowest layer.

Keeping the token set small enough to learn #

The failure mode that arrives about a year in is not drift — it is sprawl. Every team that needed a slightly different shade contributed one, every one-off spacing value became a token so it could be “shared”, and the file grows to several hundred entries that nobody can hold in their head. At that point teams stop looking for the right token and start hard-coding again, which is exactly where you began.

Two admission rules keep the set learnable. A token is added only when at least two remotes need the same value — a single consumer means it is a local value wearing a shared name, and it belongs in that remote’s own namespaced property. And a token must describe intent rather than appearance: --color-danger survives a rebrand, --color-orange becomes a lie the first time the brand changes and nobody dares rename it.

It is also worth periodically auditing which tokens are actually referenced. A scan across every remote’s stylesheets for var(--…) usage produces a list of tokens nobody reads, and removing them is one of the few maintenance tasks with no downside — an unused token cannot break a consumer, and every one removed makes the remainder easier to find.