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.

Four layers of style isolation, cheapest first Each layer closes a gap the one above it leaves open, and only the last one carries real integration cost. What actually prevents a collision Hashed class names cart__root__a91f3 · two remotes can never share a name Banned selector shapes no bare elements, ids or :global · the part hashing cannot protect Cascade layers order declared, not accidental · load order stops deciding the winner Shadow DOM, where it earns it total isolation for code you do not control · tokens still cross the boundary
The first three cost a build configuration and a lint file — reach for the fourth only when you cannot audit the CSS.

Prerequisites #

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 #

Why load order must not decide the cascade Without layers, which remote's rule wins depends on which remote mounted first, and that changes between routes and between users. No cascade layers Winner decided by stylesheet load order Load order varies by route and by user A late-mounting remote wins by accident Fixes escalate into !important Explicit layer order Shell declares reset, tokens, remotes, overrides Order is fixed regardless of load order Every remote sits in one layer The shell has a named place to override
This is the failure that produces irreproducible styling bugs — the winner genuinely differs between two users on the same page.

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 #

Detecting an unscoped rule in the composed page Walking the live stylesheet list finds rules that escaped scoping regardless of which remote or build step produced them. A test only the composed page can run Load composed page preview deployment Walk styleSheets skip cross-origin Flag unscoped rules bare type or id selector Fail with the selector names the offending rule
Reporting the selector text is what makes the failure actionable — the rule usually identifies its author immediately.

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 #

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.