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.
Prerequisites #
This guide assumes:
- A remote that renders into a DOM node you control (any framework, or vanilla DOM).
- The remote’s compiled CSS available either as a string, a same-origin URL, or a constructable
CSSStyleSheet. If you build the remote with Vite, see Handling CSS and Asset Loading in Vite Remotes for emitting CSS as an importable artifact instead of auto-injecting it intodocument.head. - A browser supporting
adoptedStyleSheets(Chrome 73+, Firefox 101+, Safari 16.4+). For older Safari, the<style>fallback in Step 4 keeps things working. - TypeScript 5.x is used in examples but every snippet runs as plain JavaScript with types removed.
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.
- Host can’t reach in: In devtools, run
document.querySelector('.remote-root'). It should returnnull— the node lives behind the boundary. Add a deliberately aggressive host rule like* { color: red !important; }and confirm the remote’s text color is unchanged. - Remote can’t leak out: Give a remote element a class that also exists on the host (e.g.
.btn) and verify the host’s.btnkeeps its own styling. Inspect a host element’s Computed panel and confirm none of the remote’s declarations appear. - Theme vars apply: Set
remote-frame { --brand-color: lime }in the Styles panel and watch the remote button recolor instantly. Removing the variable should snap it back to its fallback. - Events compose: Add
frame.addEventListener('remote:checkout', e => console.log(e.detail))on the host and trigger the action; the log should fire only whencomposed: trueis set.
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.