Shadow DOM Style Isolation for Micro-Frontends #

When a remote and its host ship independent stylesheets into the same page, their CSS cascades collide — and the fix is to render the remote inside a custom element with an attached shadow root so neither side’s styles can reach the other.

This is the visual counterpart to Using Custom Elements to Isolate Component State: the same shadow boundary that scopes DOM and lifecycle also scopes CSS. Below is a runnable wrapper that mounts a remote’s UI into a shadow root, injects its styles cleanly, and exposes a deliberate theming seam through CSS custom properties.

Shadow DOM style isolation with CSS variable seam Host styles and remote styles are blocked at the shadow boundary, while CSS custom properties pass through it. Host document global.css reset / utilities shadow boundary Shadow root remote.css remote UI CSS blocked --brand-color custom properties pierce the boundary
The shadow boundary blocks ordinary CSS in both directions, while CSS custom properties inherit straight through it as the theming seam.

Prerequisites #

This guide assumes:

Step-by-Step Implementation #

1. Define the custom element wrapper #

Create a custom element whose only job is to own the shadow boundary and host the remote. Keep it framework-agnostic so any remote can mount inside it.

// remote-frame.ts
export class RemoteFrame extends HTMLElement {
  private root!: ShadowRoot;
  private mountPoint!: HTMLDivElement;

  connectedCallback() {
    // 'open' lets you inspect shadowRoot in devtools; 'closed' hides it entirely.
    this.root = this.attachShadow({ mode: 'open' });

    this.mountPoint = document.createElement('div');
    this.mountPoint.className = 'remote-root';
    this.root.append(this.mountPoint);
  }

  disconnectedCallback() {
    // Hand off to the remote's own teardown (see Step 3).
    this.dispatchEvent(new CustomEvent('remote-unmount'));
  }
}

customElements.define('remote-frame', RemoteFrame);

Using attachShadow({ mode: 'open' }) builds an encapsulated subtree. Host stylesheets stop at the boundary — they cannot select .remote-root or anything inside it — and the remote’s styles, once injected, cannot escape it. This is the foundation for everything in Custom Elements for State Encapsulation.

2. Render the remote into the shadow root #

Hand the shadow’s mount point to the remote instead of a node in the light DOM. The remote never sees document.body; it only sees its own subtree.

// host wiring
import './remote-frame';
import { mountRemote } from 'remoteApp/mount'; // exposed via Module Federation

const frame = document.querySelector('remote-frame') as RemoteFrame;
// Expose a typed accessor for the mount point so the host can drive it.
const target = frame.shadowRoot!.querySelector('.remote-root') as HTMLElement;

const instance = mountRemote(target, { user: currentUser });

frame.addEventListener('remote-unmount', () => instance.unmount());

A typical mountRemote for a React remote looks like this:

// remote/src/mount.ts
import { createRoot, Root } from 'react-dom/client';
import App from './App';

export function mountRemote(el: HTMLElement, props: Record<string, unknown>) {
  let root: Root | null = createRoot(el);
  root.render(<App {...props} />);
  return {
    unmount() {
      root?.unmount();
      root = null;
    },
  };
}

3. Inject the remote’s styles into the shadow root #

This is the step that actually isolates CSS. Styles must be attached to the shadow root, not to document.head. The cleanest path is adoptedStyleSheets with a constructable CSSStyleSheet, which is shared, cached, and avoids a layout-thrashing <style> parse per instance.

// build a sheet once, share it across every instance
const sheet = new CSSStyleSheet();
// remoteCss is the remote's compiled CSS as a string (imported as ?raw or fetched).
sheet.replaceSync(remoteCss);

export class RemoteFrame extends HTMLElement {
  private root!: ShadowRoot;

  connectedCallback() {
    this.root = this.attachShadow({ mode: 'open' });
    this.root.adoptedStyleSheets = [sheet]; // scoped to THIS shadow root only
    const mount = document.createElement('div');
    mount.className = 'remote-root';
    this.root.append(mount);
  }
}

Because sheet is constructed once and adopted by reference, ten instances of <remote-frame> share a single parsed stylesheet — no duplicated <style> blocks, no re-parse cost.

4. Fall back to a scoped <style> where needed #

If you target a browser without adoptedStyleSheets, or the remote ships CSS as a URL, append a <style> or <link> inside the shadow root. It is still fully scoped — the boundary doesn’t care which mechanism delivers the rules.

function injectStyles(root: ShadowRoot, css: string) {
  if ('adoptedStyleSheets' in Document.prototype) {
    const sheet = new CSSStyleSheet();
    sheet.replaceSync(css);
    root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
  } else {
    const style = document.createElement('style');
    style.textContent = css;
    root.append(style); // scoped because it lives in the shadow tree
  }
}

5. Expose CSS custom properties as the theming seam #

Total isolation has a downside: the host can no longer restyle the remote even when it should — for brand color, spacing, or dark mode. CSS custom properties are the escape hatch. They inherit through the shadow boundary, so a value set on the host element cascades into the remote’s rules.

Inside the remote’s CSS, consume variables with sensible fallbacks:

/* remote.css — consumed inside the shadow root */
.remote-root .btn {
  background: var(--brand-color, #0f77c7);
  border-radius: var(--brand-radius, 6px);
  font-family: var(--brand-font, system-ui, sans-serif);
  color: var(--brand-on-color, #ffffff);
}

From the host, set those variables on the custom element. They pierce the boundary and override the fallbacks — without giving the host any other access to the remote’s internals:

/* host stylesheet — the only sanctioned way in */
remote-frame {
  --brand-color: #f98d4a;
  --brand-radius: 10px;
  --brand-font: 'Inter', system-ui, sans-serif;
}

Treat this variable list as a published contract. Document the names, defaults, and meaning so consuming teams theme through the seam instead of reaching for !important overrides that the boundary will (correctly) ignore.

6. Handle focus and events crossing the boundary #

Styles stay in, but you still want clicks and keystrokes to flow out. Native events are retargeted at the boundary; custom events are not composed by default, so they will not escape unless you opt in.

// inside the remote — let the host hear this
el.dispatchEvent(new CustomEvent('remote:checkout', {
  detail: { cartId },
  bubbles: true,
  composed: true, // REQUIRED to cross the shadow boundary
}));

Focus moves into the shadow tree naturally, and document.activeElement reports the host element while shadowRoot.activeElement reports the focused inner node. Use :focus-visible inside the remote’s CSS for keyboard rings, and remember the host’s global focus styles will no longer apply — that is the point.

Verification #

Confirm isolation works in both directions before shipping.

A Playwright assertion locks the behavior in CI:

test('host styles do not affect the remote', async ({ page }) => {
  await page.addStyleTag({ content: '.btn { background: red !important }' });
  const bg = await page.evaluate(() => {
    const frame = document.querySelector('remote-frame')!;
    const btn = frame.shadowRoot!.querySelector('.btn')!;
    return getComputedStyle(btn).backgroundColor;
  });
  expect(bg).not.toBe('rgb(255, 0, 0)');
});

Troubleshooting #

Symptom: global host styles still appear inside the remote. Diagnosis: the remote is rendering into the light DOM, not the shadow root — usually because mountRemote received a light-DOM node, or the remote auto-injected its CSS into document.head at import time. Fix: pass the shadow’s .remote-root as the mount target, and configure the remote build to emit CSS as a string/asset (see the Vite CSS guide linked above) instead of auto-injecting it.

Symptom: @font-face fonts don’t load inside the shadow root. Diagnosis: @font-face declarations are resolved at the document level, not the shadow level, so a rule defined only inside the shadow sheet may be ignored. Fix: declare @font-face once in the host document (fonts are not visual-style leakage — they’re a shared resource), then reference the family inside the shadow via var(--brand-font). Keep relative url() paths absolute so they resolve regardless of where the sheet was constructed.

Symptom: a third-party library renders a dropdown or modal that is unstyled or misplaced. Diagnosis: many libraries append overlays to document.body, which lands outside the shadow root where the remote’s scoped CSS can’t reach them. Fix: configure the library to render into a container inside the shadow root (most expose an appendTo / container / getPopupContainer option), or duplicate the overlay’s CSS into the host document. This is the main caveat of portals and overlays under Shadow DOM — plan for it per component.

Symptom: a focus trap or keyboard handler can’t find the focused element. Diagnosis: code reading document.activeElement sees the host custom element, not the inner node, because the real target is hidden behind the boundary. Fix: traverse with element.shadowRoot?.activeElement (recursively for nested shadow roots), and ensure any custom keyboard events use composed: true so they bubble out to host-level listeners.