Design System and Theming Across Remotes #

Visual coherence is the first thing an organisation loses when it splits a frontend into independently-deployed remotes, and it is the loss users notice. Four teams shipping on four schedules produce four slightly different buttons, and no amount of design review catches it, because each team’s work looks correct in isolation.

This guide covers the mechanism that keeps a federated interface looking like one product: a single source of design tokens, a theme that switches at runtime across every remote at once, style isolation strong enough that one team cannot break another, and a component library that can be versioned without lockstep releases. It sits under Cross-App State & Context Sharing, because a theme is shared state with a rendering deadline. Its step-by-step companions cover sharing design tokens across micro-frontends, switching themes at runtime across remotes, avoiding CSS collisions between remote apps, and versioning a shared component library as a remote.

What breaks without a deliberate approach #

How visual coherence degrades across independent remotes Each failure has a different cause and a different fix, and the last one is the compounding consequence of leaving the first three unaddressed. Four failures, in the order they appear Drift each remote pins its own design system version · three button generations on one page Collision two remotes ship global styles · one team's deploy breaks another's layout Theme desynchronisation shell switches, one remote does not · half the page stays light Erosion of trust teams add defensive overrides · specificity war nobody wins
The final band is why this is worth solving early: defensive overrides are far harder to remove than to prevent.

Three failures appear in a predictable order, and each is caused by a different missing mechanism.

Drift comes first. Each remote imports the design system at whatever version it last upgraded to, and because they upgrade on different schedules, the page renders three generations of the button component simultaneously. Nothing is broken; it simply looks unconsidered.

Collision comes second, and it is louder. Two remotes ship global styles, one team’s .card rule wins, and a remote that has not changed in months is suddenly broken by a deploy from a team it has never spoken to. This is the failure that erodes trust between teams fastest, because the affected team cannot fix it.

Theme desynchronisation comes last and is the most visible to users. The shell switches to dark mode, three remotes follow, one does not, and the page is half dark for the rest of the session.

Key objectives #

Setup: tokens as CSS custom properties #

CSS custom properties are the right transport, for a reason specific to federation: they cross every boundary that matters. They inherit through the DOM, they cross the shadow boundary, they need no JavaScript, and they cost nothing to change at runtime.

/* @acme/tokens — published once, applied by the shell to :root */
:root {
  --color-surface: #ffffff;
  --color-text: #10324d;
  --color-accent: #0f77c7;
  --space-2: 0.5rem;
  --space-3: 0.75rem;
  --radius-md: 10px;
  --font-body: Inter, system-ui, sans-serif;
}

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

The rule that makes this work is short and absolute: remotes consume tokens, they never define them. A remote that sets --color-accent has quietly become a second source of truth, and the next brand change will miss it.

Enforce it rather than documenting it. A lint rule that fails a remote’s build when it declares a custom property in the shared namespace costs an afternoon and removes an entire category of drift.

// eslint config in every remote
'no-restricted-syntax': ['error', {
  selector: "Property[key.value=/^--(color|space|radius|font)-/]",
  message: 'Remotes consume design tokens; only @acme/tokens may define them.',
}],

Integration: what a remote is allowed to assume #

The contract between the shell and a remote Writing down what a remote may and may not rely on prevents teams from reverse-engineering assumptions that later become impossible to change. What a remote is allowed to rely on Surface May assume Must not assume Token custom properties defined on :root before mount Active theme readable from data-theme Shared primitives available as a singleton Host class names no remote may select them Host CSS reset ship your own or use none Style exclusivity another remote's CSS is on the page
Every item in the right-hand column is a way one team's implementation detail becomes another team's dependency.

A remote needs a contract describing what it can rely on. Without one, teams reverse-engineer it from whatever happens to work, and the assumptions they form become impossible to change.

Three things a remote may assume: the token custom properties are defined on :root before it mounts; the data-theme attribute on the document element names the active theme; and the shared component library is available as a federated singleton.

Three things a remote must not assume: that any host class name exists, that the host’s reset or normalise stylesheet has been applied, or that its own styles will not be loaded alongside another remote’s. Each of those assumptions is one team coupling to another team’s implementation detail.

/* A remote's styles: tokens in, no host selectors, scoped class names */
.cart-root {
  background: var(--color-surface, #ffffff);   /* fallback for standalone dev */
  color: var(--color-text, #10324d);
  padding: var(--space-3, 0.75rem);
  border-radius: var(--radius-md, 10px);
}

The fallback values are what let the remote run standalone, and they should be the light-theme defaults rather than something arbitrary — a remote that looks wrong in its own dev server will have its styles “fixed” in a way that breaks composition.

Integration: the component library as a federated remote #

Shipping the design system as a federated remote rather than an npm package changes the upgrade dynamics completely.

As a package, every consumer pins a version and upgrades on its own schedule, which is exactly what produces three button generations on one page. As a remote, the shell resolves one version at runtime and every consumer gets it — a design system release reaches production without any team deploying.

That power cuts both ways, which is the honest trade. A breaking change in a federated design system breaks every remote simultaneously, with no per-team staging. The discipline that makes it safe is the additive-change policy from backward-compatible remote API contracts: add, never replace, and remove only after every consumer has moved.

Most organisations end up with both. Tokens and primitives ship as a federated remote because they change rarely and benefit from being uniform; higher-level composite components ship as a package because a team may legitimately want to hold back.

Edge cases #

A remote mounts after a theme change. Custom properties inherit from :root, so a remote that reads them at render time is automatically correct. A remote that copied values into JavaScript state at boot is not — which is the main argument for keeping theme in CSS rather than in a store.

Shadow DOM blocks the host stylesheet. By design, and it does not matter: custom properties inherit through the shadow boundary. A remote in a shadow root still reads --color-accent correctly, which is what makes shadow DOM style isolation compatible with a shared design system.

An iframe-isolated remote sees nothing. Correct, and it needs the theme passed explicitly over postMessage as a token name, not as CSS text — see isolating untrusted remotes with iframes.

Two remotes need different versions of a component. This is a genuine conflict, not a technical problem. Either the component’s API is not backward-compatible enough, or the two use cases are different components wearing the same name.

Testing and validation #

Check Runs where Catches
Token definition lint Every remote’s CI A remote defining a shared token
Hard-coded colour lint Every remote’s CI A hex value bypassing the token system
Theme switch, composed Host CI A remote that does not follow the theme
Visual regression, composed Host CI Drift and collision between remotes
Component library contract Design system CI A breaking change to a shared primitive

Visual regression only belongs at the composed level. Per-remote visual tests overwhelmingly capture the remote team’s own intentional changes, while the failures worth catching — a collision, a drifted button, a theme a remote ignored — exist only when remotes render together.

Deployment #

Tokens and components have different release cadences and should be deployed differently.

Token values can change without any consumer deploying, because they are read at runtime. A rebrand is a single deploy of the token remote. That is a genuinely valuable property and it is worth protecting by keeping the token surface small and stable.

Token names are a contract and changing one is a breaking change. Add the new name, keep the old one aliased to it, and remove the alias a major version later — the same policy as any other federated seam.

/* One release: new name added, old name aliased. Remove the alias next major. */
:root {
  --color-accent: #0f77c7;
  --color-primary: var(--color-accent);   /* deprecated, still works */
}

Component releases follow the version policy of any remote, with one addition: because a design system release reaches every consumer at once, it deserves a progressive rollout. The mechanism in progressive rollout with feature flags for remotes applies directly, and the blast radius makes it more valuable here than almost anywhere else.

Why tokens beat a shared theme object #

One token source, read identically by every remote Custom properties inherit through every boundary that matters, so a React remote, a Vue remote and a shadow-rooted widget all read the same value with no adapter. Shell applies tokens owns data-theme React remote var(--color-accent) @acme/tokens (federated singleton) one set of values, applied by the shell to :root Vue remote var(--color-accent) Shadow-DOM widget inherits through the boundary
This is why tokens beat any JavaScript theming API: the transport is the platform, and every framework already speaks it.

The instinct in a JavaScript-heavy codebase is to make the theme a shared module — an object of values, exported as a federated singleton, consumed through a hook or a context provider. It works, and it is worse than CSS custom properties in four specific ways that matter in exactly this architecture.

It couples every remote to a framework. A theme object consumed through a React hook is unusable from a Vue remote, a Svelte remote, or a plain custom element without an adapter per framework. Custom properties have no such requirement, which is what makes a mixed-framework estate practical at all.

It requires a re-render to change. Switching a theme object means every subscribed component re-renders across every remote simultaneously — a large, synchronous cost at exactly the moment the user is watching. Changing a custom property on :root is a style recalculation the browser is heavily optimised for, with no JavaScript involved.

It breaks for remotes that mount later. A remote that boots after a theme change reads the current object correctly, but a remote that captured values into its own state at boot does not — and this is the most common cause of a single remote staying light when the page goes dark. Custom properties have no capture step to get wrong.

And it fails at the shadow boundary. A shared JavaScript object is reachable from inside a shadow root only if the component is passed a reference to it; custom properties inherit across the boundary automatically, which is what lets shadow DOM isolation and a shared design system coexist.

The pragmatic exception is JavaScript that needs a token value rather than a style — a canvas chart choosing a series colour, or a library configured with a hex string. For that, read the computed property at the point of use rather than mirroring the whole theme into state.

// Read the live value; never cache it across a theme change.
const accent = getComputedStyle(document.documentElement)
  .getPropertyValue('--color-accent').trim();

Governance: who owns the design system in a federated organisation #

The technical mechanism is the easy half. The harder question is who decides what a token means and who is allowed to change it, and getting that wrong produces either a design system nobody adopts or one that changes so often nobody can rely on it.

The arrangement that holds is a small owning team with a narrow mandate and a wide review obligation. The design system team owns the token names, the primitive component APIs and the release process. It does not own how remotes compose primitives, and it should resist requests to add product-specific components — a component used by one remote belongs in that remote.

Contribution has to be genuinely open, because a design system that only its owners can change becomes a bottleneck, and a bottleneck produces exactly the defensive local overrides the system exists to prevent. The practical shape is that any team may propose a primitive, the owning team reviews for API consistency and cross-remote fit, and the change ships through the normal release process.

Two rules keep the surface from sprawling. A token is added only when at least two remotes need it — one consumer means it is a local value wearing a shared name. And a primitive is promoted into the shared library only after it has existed in two remotes and proven that the second use did not require a new prop for every difference. Both rules are easy to state and quietly do most of the work of keeping a design system small enough to be learnable.

Finally, someone has to own the composed visual regression suite, and it should be the design system team rather than the platform team. They are the only group with a reason to look at every remote’s rendered output, and the suite is the closest thing the organisation has to an automated definition of “looks like one product”.

A last note on adoption sequencing. Introduce tokens before primitives, and enforce the lint rules before either. Teams will happily consume a token file that already exists; asking them to adopt a component library while their own styles still hard-code colours produces a half-migration that is worse than neither, because the page now mixes two systems rather than one inconsistent one.

Common pitfalls #

Pitfall Root cause and resolution
Three button generations on one page Design system shipped only as a package. Ship primitives as a federated remote.
One remote breaks another’s layout Global styles from both. Enforce scoped class names in the build.
Theme switches everywhere but one remote That remote read token values into JavaScript at boot. Read tokens in CSS.
A rebrand needs every team to deploy Values baked into remote builds. Move them to runtime custom properties.
Remote looks wrong in its own dev server No fallback values on var(). Add light-theme defaults.
Visual tests pass, composed page is wrong Regression tests run per remote. Run them on the composition.

FAQ #

Should the design system be a package or a remote?

Both, split by change rate. Tokens and primitives — button, input, card — belong in the federated remote, because uniformity matters more than a team’s ability to hold back. Composite components that encode product decisions belong in a package, because a team may reasonably want an older version while it migrates.

How do we stop teams from hard-coding colours?

A lint rule that rejects hex values and named colours in remote stylesheets, with an allow-list for the token file itself. Documentation does not survive contact with a deadline; a failing build does.

Can remotes extend the design system?

They can compose, not extend. A remote may build a component out of primitives and may define its own private custom properties in a namespaced prefix. It may not redefine a shared token, because that makes the token’s value depend on which remotes happen to be on the page.

What about a remote written in a different framework?

Tokens are framework-neutral, which is their main advantage here. A Vue or Svelte remote reads the same custom properties and looks native. Shared components, by contrast, are framework-bound — which is a good reason to keep the shared layer at the token and primitive level rather than pushing composite components across framework boundaries.

How do we roll back a bad design system release?

The same way as any remote: re-point the manifest at the previous version. This is precisely why shipping the design system as a federated remote is safer than it first sounds — a package rollback requires every consumer to deploy, while a remote rollback is one pointer change.