Switching Themes at Runtime Across Remotes #

A theme toggle that changes four remotes and misses the fifth is worse than no toggle — this page makes a single attribute on the document element the only source of truth, so every remote follows automatically and late-mounting ones are correct on their first frame.

The design is deliberately boring: one attribute, CSS that responds to it, and a small notification channel only for the code that genuinely cannot use CSS. Everything else follows from that. It implements the theming half of design system and theming across remotes.

How each kind of remote follows a theme change Three of the five categories need no code at all, because the transport is CSS inheritance rather than an event anyone has to subscribe to. Remote type How it follows Code required Normal remote inherits :root custom properties none Late-mounting remote reads current values on first render none Shadow-DOM remote properties inherit across the boundary none Canvas or chart remote subscribes and redraws a subscription Iframe-isolated remote receives the theme name by message a message handler
Design for the top three and the system is correct by default; the bottom two are the deliberate exceptions.

Prerequisites #

Step 1 — Make one attribute the source of truth #

Every theme-dependent style in the system keys off data-theme on <html>. Nothing else.

/* @acme/tokens — the whole theme system, from the remotes' point of view */
:root, :root[data-theme="light"] {
  color-scheme: light;
  --color-surface: #ffffff;
  --color-text: #10324d;
  --color-accent: #0f77c7;
}

:root[data-theme="dark"] {
  color-scheme: dark;
  --color-surface: #11283a;
  --color-text: #e8f2fb;
  --color-accent: #7cc2ff;
}

Setting color-scheme alongside the tokens is worth the line: it makes form controls, scrollbars and the default canvas follow the theme without any remote doing anything, which removes a whole category of “one widget is still light” reports.

Because the attribute lives on the root and custom properties inherit, a remote that mounts an hour after a theme change renders correctly on its first frame with no subscription, no event and no code.

Step 2 — Apply the theme before first paint #

A theme applied after hydration produces a visible flash of the wrong theme, which is the single most-reported problem with any theme system.

<!-- host index.html — inline, synchronous, before any stylesheet-dependent paint -->
<script>
  (function () {
    try {
      var stored = localStorage.getItem('theme');
      var theme = stored === 'dark' || stored === 'light'
        ? stored
        : (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
      document.documentElement.setAttribute('data-theme', theme);
    } catch (e) {
      document.documentElement.setAttribute('data-theme', 'light');
    }
  })();
</script>

This must be inline and synchronous. An external script — even one marked defer — runs after the first paint, and the flash is back.

The try block is not defensive padding: localStorage throws in a sandboxed iframe and in some privacy modes, and an uncaught throw here leaves the page with no theme at all.

Step 3 — Give the shell sole ownership of changes #

Remotes request, the shell applies Concentrating every write in one module means the theme can never be half-applied, and remotes stay unaware of each other entirely. Remote Shell theme module html[data-theme] Every remote 1. requestTheme('dark') 2. setAttribute — the only writer 3. custom properties recompute 4. notify subscribers (canvas only)
The fourth message reaches only the handful of remotes that cannot express themselves in CSS.

Remotes request; the shell decides and applies. That asymmetry is what keeps the system predictable.

// shell/src/theme.js — the only code that writes data-theme
const listeners = new Set();

export function getTheme() {
  return document.documentElement.getAttribute('data-theme') ?? 'light';
}

export function setTheme(next) {
  if (next !== 'light' && next !== 'dark') return;
  document.documentElement.setAttribute('data-theme', next);
  try { localStorage.setItem('theme', next); } catch { /* storage blocked */ }
  listeners.forEach((fn) => fn(next));
}

export function subscribe(fn) {
  listeners.add(fn);
  return () => listeners.delete(fn);
}

Expose this from the shell as a federated singleton so remotes import the same instance. Two copies means two listener sets, and whichever remotes subscribed to the wrong one are never notified.

Most remotes will never import it. The subscription exists for the small amount of code that cannot express itself in CSS.

Step 4 — Handle the code that genuinely needs JavaScript #

Canvas rendering, chart libraries configured with hex strings, and the theme-color meta tag all need a value rather than a style.

// A chart remote reacting to the theme
import { subscribe, getTheme } from 'shell/theme';

function seriesColours() {
  const style = getComputedStyle(document.documentElement);
  // Read live, every time. A cached value is wrong after the first switch.
  return ['--chart-1', '--chart-2', '--chart-3']
    .map((name) => style.getPropertyValue(name).trim());
}

export function mountChart(el) {
  const draw = () => renderChart(el, { colours: seriesColours() });
  draw();
  return subscribe(draw);   // returns the unsubscribe function
}

Returning the unsubscribe function and calling it on unmount is not optional. Remotes mount and unmount on every navigation, and a leaked listener holds a reference to a detached element for the life of the page.

For remotes that cannot import the shell’s module — a different framework, or a lazily-loaded island — a MutationObserver on the attribute achieves the same thing with no coupling at all:

const observer = new MutationObserver(() => draw());
observer.observe(document.documentElement, {
  attributes: true, attributeFilter: ['data-theme'],
});

Step 5 — Propagate to isolated remotes #

Theme propagation by isolation level Only a separate document needs an explicit channel, and even then what crosses is a theme name rather than any styling. Where does this remote live? Same document Nothing to do Inherits from :root Correct on first frame Shadow root Nothing to do Properties cross the boundary Isolation stays intact Iframe, separate document Send the theme name postMessage on change and init Frame applies its own tokens
Sending CSS text to a frame would work and would couple it to your internals — the name is the contract.

Shadow DOM needs nothing: custom properties inherit through the boundary, so a shadow-rooted remote is already correct.

Iframes need an explicit message, because they are a separate document with a separate root element.

// shell — tell every framed remote when the theme changes
subscribe((theme) => {
  document.querySelectorAll('iframe[data-remote]').forEach((frame) => {
    frame.contentWindow?.postMessage(
      { v: 1, type: 'theme', theme },
      frame.dataset.origin,     // never '*'
    );
  });
});
// inside the framed remote
window.addEventListener('message', (event) => {
  if (event.origin !== HOST_ORIGIN) return;
  if (event.data?.type !== 'theme') return;
  document.documentElement.setAttribute('data-theme', event.data.theme);
});

Send the theme name, never CSS text. A name is a stable contract; CSS couples the frame to the host’s internal implementation and breaks on the next refactor. The framed remote applies its own copy of the tokens for that name, exactly as it would standalone.

Send the current theme in the frame’s init message too, so a frame that loads after a switch does not start on the wrong one.

Step 6 — Make the transition not look broken #

Switching several remotes at once produces a large repaint, and an unconsidered CSS transition on it looks like the page is melting.

/* Transition only what reads well, and never during the initial load. */
:root.theme-ready * {
  transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease;
}

@media (prefers-reduced-motion: reduce) {
  :root.theme-ready * { transition: none; }
}
// Add the class after first paint so the initial render is not animated.
requestAnimationFrame(() => document.documentElement.classList.add('theme-ready'));

Limit the transition to colour properties. A universal transition: all animates layout properties too, which turns a theme switch into a visible reflow across every remote simultaneously.

Step 7 — Test that every remote actually follows #

The failure is one remote out of six, which no per-remote test can see.

// e2e/theme.spec.js
import { test, expect } from '@playwright/test';

test('every remote follows a theme switch', async ({ page }) => {
  await page.goto('/checkout');
  await expect(page.locator('[data-remote="cart"]')).toBeVisible();

  const before = await readSurfaces(page);
  await page.getByRole('button', { name: /dark/i }).click();
  await page.waitForFunction(() => document.documentElement.dataset.theme === 'dark');
  const after = await readSurfaces(page);

  for (const [name, colour] of Object.entries(after)) {
    expect(colour, `${name} did not change`).not.toBe(before[name]);
  }
});

async function readSurfaces(page) {
  return page.evaluate(() => Object.fromEntries(
    [...document.querySelectorAll('[data-remote]')].map((el) =>
      [el.dataset.remote, getComputedStyle(el).backgroundColor]),
  ));
}

Add a second test that mounts a remote after the switch — navigate to a route that lazily loads one — and assert it renders in the current theme on its first frame. That is the case a subscription-based design gets wrong and a CSS-based one gets right for free.

Verification #

Troubleshooting #

Symptom: one remote stays light.

Diagnosis: it read token values into JavaScript at boot, or it hard-codes colours. Fix: replace cached values with live reads or plain CSS, and run the token lint in that remote’s pipeline.

Symptom: the page flashes the wrong theme on load.

Diagnosis: the theme is applied by the application bundle rather than by an inline head script. Fix: move the boot script inline and synchronous, as in step 2.

Symptom: a remote follows the first switch and not the second.

Diagnosis: two copies of the shell’s theme module, so the remote subscribed to a listener set nobody notifies. Fix: share the module as a federation singleton.

Symptom: the transition looks like the page is reflowing.

Diagnosis: transition: all is animating layout properties. Fix: restrict the transition to colour properties, and honour reduced-motion.

Symptom: an iframe remote is on the wrong theme after loading late.

Diagnosis: the theme is sent on change but not in the frame’s init message. Fix: include the current theme in init, as in step 5.

Beyond light and dark #

Once the mechanism exists, the obvious next request is a third theme — a high-contrast mode, a seasonal brand, a per-tenant palette in a multi-tenant product. The good news is that nothing in this design is binary: data-theme holds a name, and a name can be anything.

The part that does not scale for free is the token file. Every additional theme multiplies the values the design system has to maintain and the combinations the visual regression suite has to cover, and a theme that is only partially defined degrades to whichever values it happens to inherit. Adding a theme should therefore be a deliberate design-system release with full token coverage, not a remote shipping a few overrides.

Two constraints keep it manageable. Themes are defined only in the token package, never by a remote, for the same reason tokens are — a per-tenant palette a remote defines is invisible to every other remote. And the set of valid theme names is published as part of the token contract, so the shell can reject an unknown name rather than applying an attribute that matches no stylesheet and silently falls back.

High-contrast deserves one specific note: prefer prefers-contrast: more as an additional layer over the current theme rather than as a separate theme name. It composes with light and dark instead of doubling the matrix, and it follows the user’s OS setting without a toggle anyone has to find.