Avoiding CSS Collisions Between Remote Apps #
Every remote’s stylesheet lands in one global cascade, so a .card rule written by one team can silently restyle another team’s component — this page removes that possibility with hashing, cascade layers and a build-enforced scope.
The characteristic of this bug is that the affected team cannot fix it. Their code did not change, their tests pass, and the cause is a deploy from a team they may never have spoken to. That is why convention is not enough here: the isolation has to be something the build guarantees. It implements the isolation objective of design system and theming across remotes.
Prerequisites #
- Webpack 5 or Vite 5 per remote, with CSS Modules available.
- A shared design token stylesheet applied by the shell, per sharing design tokens across micro-frontends.
- A composed preview environment, since collisions only exist in composition.
- Agreement that no remote ships rules targeting bare element selectors.
Step 1 — Make every class name globally unique by construction #
CSS Modules hash class names at build time, which removes name collisions entirely without asking anyone to remember a prefix.
// remote/webpack.config.js
module.exports = {
module: {
rules: [{
test: /\.module\.css$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
modules: {
// The remote name in the pattern makes DevTools readable.
localIdentName: 'cart__[local]__[hash:base64:5]',
},
},
},
],
}],
},
};
Including the remote’s name in the pattern costs a few bytes and pays for itself the first time someone inspects an element in the composed page and needs to know which team owns the rule.
Then reject non-module stylesheets outright, so nobody can bypass the mechanism:
// A plain .css import is a build error — every stylesheet must be a module.
{
test: /\.css$/,
exclude: /\.module\.css$/,
use: [{ loader: 'error-loader', options: { message: 'Use *.module.css in remotes.' } }],
}
Step 2 — Ban the selectors that hash cannot protect #
Hashing protects class names. It does nothing about element selectors, attribute selectors or anything a remote writes under :global.
// stylelint.config.js — shipped by the design system, extended by every remote
module.exports = {
rules: {
// No bare element selectors: `button { … }` restyles every remote's buttons.
'selector-max-type': 0,
// No id selectors: they win almost every specificity contest.
'selector-max-id': 0,
// :global escapes the module scope entirely.
'selector-pseudo-class-disallowed-list': ['global'],
// No !important: it is how specificity wars start.
'declaration-no-important': true,
},
};
selector-max-type: 0 is the rule that prevents the most damage. A single button { border-radius: 0 } in one remote restyles every button on the page, and the affected teams have no way to know where it came from without reading everyone’s CSS.
Allow a narrow exception for a remote’s own root, where a type selector scoped under a hashed class is safe:
/* Fine: scoped under a hashed module class. */
.root button { font: inherit; }
Step 3 — Order the cascade explicitly with layers #
Even without collisions, the order remote stylesheets load in decides who wins — and that order depends on which remote mounted first, which changes per route and per user. Cascade layers make the ordering explicit instead.
/* Applied by the shell, before any remote loads. */
@layer reset, tokens, primitives, remotes, overrides;
/* Every remote wraps its own styles in the remotes layer. */
@layer remotes {
.root { background: var(--color-surface); }
}
Layer order is decided by the declaration above, not by load order, so a remote that mounts late no longer wins by accident. Within remotes the usual rules still apply — which is fine, because module hashing means two remotes’ rules never target the same element in the first place.
Reserve overrides for the shell. It gives the platform team a way to correct a misbehaving remote in an incident without a specificity fight, and having a named place for it discourages !important from appearing anywhere else.
Step 4 — Handle remotes that inject their own stylesheets #
A federated import fetches JavaScript, so a remote that emits a separate CSS file has to inject it. Doing that carelessly is a second collision source.
// remote/src/styles.js — idempotent injection, into the right layer
import cssText from './styles.css?raw';
const ID = 'cart-styles';
export function injectStyles() {
if (document.getElementById(ID)) return; // idempotent: mounts twice, injects once
const el = document.createElement('style');
el.id = ID;
el.textContent = `@layer remotes { ${cssText} }`;
document.head.appendChild(el);
}
The idempotence check matters because remotes mount and unmount repeatedly. Without it, a remote that mounts five times over a session has five copies of its stylesheet on the page, each one adding to the cascade.
Wrapping the injected text in the layer at injection time is what keeps a Vite remote — whose CSS pipeline strips or reorders @layer in some configurations — inside the ordering. The related mechanics are covered in handling CSS and asset loading in Vite remotes.
Step 5 — Reach for shadow DOM only where it earns its cost #
Shadow DOM gives total isolation, and it is not free: portals, focus management and third-party components that append to document.body all need explicit handling.
The cases where it is worth it are narrow and identifiable. Use it for a remote whose styles you do not control — a third-party widget, an acquired product’s code — and for a remote embedded into a host you do not own. For a first-party remote inside your own shell, module hashing plus layers gives you the same practical protection with none of the friction.
// Isolation for a widget whose CSS you cannot audit
class PartnerWidget extends HTMLElement {
connectedCallback() {
const root = this.attachShadow({ mode: 'open' });
// Tokens still reach inside: custom properties inherit through the boundary.
root.innerHTML = `<style>${widgetCss}</style><div class="root"></div>`;
mount(root.querySelector('.root'));
}
}
The important property is that this remains compatible with the design system, because custom properties cross the shadow boundary. Isolation and coherence are not in tension here.
Step 6 — Detect collisions in the composed page #
A collision cannot be observed in one remote’s own test suite, so the detection has to run on the composition.
// e2e/collisions.spec.js — every rule must be scoped to exactly one remote
test('no remote ships an unscoped selector', async ({ page }) => {
await page.goto('/checkout');
await expect(page.locator('[data-remote="cart"]')).toBeVisible();
const offenders = await page.evaluate(() => {
const bad = [];
for (const sheet of document.styleSheets) {
let rules;
try { rules = sheet.cssRules; } catch { continue; } // cross-origin sheet
for (const rule of rules) {
if (!rule.selectorText) continue;
// A bare type or id selector at the top level is unscoped.
if (/^\s*[a-z]+[\s,{]/i.test(rule.selectorText) || rule.selectorText.includes('#')) {
bad.push(rule.selectorText.slice(0, 80));
}
}
}
return bad;
});
expect(offenders, offenders.join('\n')).toHaveLength(0);
});
Pair it with a visual regression run on the composed page. Between them, the structural check catches the cause and the visual check catches the effect — including collisions that are technically legal but still wrong, such as two remotes claiming the same layer for different purposes.
Step 7 — Give the shell a documented escape hatch #
Incidents happen, and when a remote ships a rule that breaks the page, the shell needs a way to fix it in minutes rather than waiting for another team’s release.
/* Shell only. Every entry carries a ticket and an owner. */
@layer overrides {
/* PLAT-2841 — [email protected] sets overflow:hidden on the page root. Remove after 5.0.2. */
html { overflow: visible; }
}
Two rules keep this from becoming permanent. Every entry carries a ticket reference and the release it is waiting on, and the file is reviewed on a schedule with anything whose blocking release has shipped removed. An override file nobody prunes becomes a second, undocumented stylesheet that future collisions will fight with.
Verification #
- Class names are hashed: inspect a remote’s element. Class names should read
cart__root__a91f3, notroot. - Layers are ordered: in DevTools, the cascade panel should show
remoteslosing tooverridesregardless of load order. - Injection is idempotent: navigate away and back several times; there should be exactly one style element per remote.
- Lint blocks a bare selector: add
button { color: red }to a remote and confirm the build fails. - No unscoped rules in composition: run the collision test against the preview and confirm zero offenders.
- Tokens still reach shadow roots: confirm a shadow-rooted remote picks up a theme change with no code.
Troubleshooting #
Symptom: a remote’s styles change when another remote deploys.
Diagnosis: one of them ships a bare element selector or a :global block. Fix: run the collision test to find it, then add the stylelint rules to that remote’s pipeline so it cannot recur.
Symptom: the same remote’s styles win on one route and lose on another.
Diagnosis: no cascade layers, so the winner depends on which remote mounted first. Fix: declare the layer order in the shell and wrap every remote’s styles in the remotes layer.
Symptom: styles accumulate as the user navigates.
Diagnosis: the injection helper is not idempotent. Fix: guard on a stable element id, as in step 4.
Symptom: a shadow-rooted remote ignores the theme.
Diagnosis: it hard-codes colours rather than reading custom properties — the boundary is not the problem. Fix: replace the literals with var(); properties inherit across the boundary.