Shadow DOM CSS Isolation: How to Embed a Widget Without Breaking the Host Page

javascript dev.to

Shadow DOM is the only reliable way to prevent a third-party widget's CSS from colliding with a host page's styles. I learned this building IssueCapture — a bug reporting widget that runs on thousands of different host pages, each with its own CSS, fonts, z-index stacking contexts, and JavaScript globals. It cannot break those pages. It cannot be broken by them.

Here's the full setup, including the parts the docs skip.

Why Shadow DOM?

The naive approach to embedding a third-party widget is dumping your HTML into the host page's DOM and hoping for the best. This works until the host page has a CSS reset that flattens all your styles, or a * { box-sizing: content-box !important; } rule, or a global .modal { display: none; } that someone added in 2019 and nobody wants to touch.

Shadow DOM gives you a genuine encapsulation boundary. Styles from the host page cannot pierce it. Your styles cannot leak out of it. You get an isolated subtree with its own document fragment.

Here's the basic mounting pattern:

const container = document.createElement('div');
container.id = 'my-widget-root';
container.style.position = 'fixed';
container.style.zIndex = '999999';
container.style.top = '0';
container.style.left = '0';
container.style.width = '100vw';
container.style.height = '100vh';
container.style.pointerEvents = 'none'; // pass-through until open
document.body.appendChild(container);

const shadowRoot = container.attachShadow({ mode: 'open' });
Enter fullscreen mode Exit fullscreen mode

The container is a fixed full-viewport div sitting on top of everything. pointerEvents: none means it's invisible to clicks when the widget is closed — the host page behaves normally. When the widget opens, you toggle that to auto.

Preact Over React

React adds about 130KB before you write a single component. That's a hard sell when you're injecting a script into someone else's production site. Every kilobyte you add is a kilobyte their users download.

Preact's core is around 3KB gzipped. It has the same API surface — hooks, context, refs, useEffect, all of it. The only things missing are some legacy React APIs nobody should be using in new code anyway.

IssueCapture's widget ships at around 40KB gzipped for the core UI. Screenshot capture is another 13KB, annotation is 107KB — both lazy-loaded only when the user actually requests them. That's the right tradeoff: pay the cost only when the feature is used.

Injecting Styles into Shadow DOM

This is where most people trip up. You cannot link external stylesheets into a shadow root. <link rel="stylesheet"> tags and @import rules do not work the way you expect inside a shadow DOM. The simplest reliable approach is constructing your CSS as a plain string and injecting it via a <style> element.

const appContainer = document.createElement('div');
appContainer.id = 'my-app';
shadowRoot.appendChild(appContainer);

const styleElement = document.createElement('style');
styleElement.textContent = `
  :host {
    all: initial;
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  }

  * {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
  }

  #my-app {
    position: relative;
    width: 100%;
    height: 100%;
  }
`;
shadowRoot.appendChild(styleElement);
Enter fullscreen mode Exit fullscreen mode

The :host selector targets the shadow host element. all: initial resets inherited CSS properties so the host page's font, color, and line-height don't bleed in through inheritance, which is one of the few ways the host page can still affect a shadow root.

With Preact, mounting into the shadow root looks identical to mounting into any other DOM node:

import { render } from 'preact';
import { MyWidget } from './MyWidget';

render(<MyWidget />, appContainer);
Enter fullscreen mode Exit fullscreen mode

The Rough Edges

Why document.activeElement Lies Inside Shadow DOM

When your modal opens, keyboard focus should stay inside it. The problem is that document.activeElement reflects focus in the main document. When focus moves into your shadow root, document.activeElement points at the shadow host — not the specific element inside the shadow. To find which element inside your shadow has focus, you need shadowRoot.activeElement.

const previousActiveElement = document.activeElement;

const focusableSelectors = [
  'button:not([disabled])',
  'input:not([disabled])',
  'select:not([disabled])',
  'textarea:not([disabled])',
  'a[href]',
].join(', ');

document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') {
    closeModal();
    return;
  }

  if (e.key === 'Tab') {
    const elements = Array.from(
      modalRef.current.querySelectorAll(focusableSelectors)
    );
    const first = elements[0];
    const last = elements[elements.length - 1];

    if (e.shiftKey && document.activeElement === first) {
      e.preventDefault();
      last.focus();
    } else if (!e.shiftKey && document.activeElement === last) {
      e.preventDefault();
      first.focus();
    }
  }
});

previousActiveElement?.focus(); // restore on close
Enter fullscreen mode Exit fullscreen mode

One subtlety: you attach the keydown listener to document, not to the shadow root. Keyboard events bubble out of shadow DOM into the main document, so this works.

Font Loading

Fonts declared in the host page's CSS are not available inside your shadow root by default. The safest approach: use system font stacks. If you need a specific font, load it at the document level by injecting a <link> tag into the host page's <head> — fonts loaded at the document level are accessible from within shadow roots.

if (!document.getElementById('my-widget-fonts')) {
  const link = document.createElement('link');
  link.id = 'my-widget-fonts';
  link.rel = 'stylesheet';
  link.href = 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap';
  document.head.appendChild(link);
}
Enter fullscreen mode Exit fullscreen mode

Z-Index Stacking Contexts

Your widget's container sits at z-index: 999999. That sounds like it should win every fight. It often doesn't.

The browser doesn't compare z-index values globally — it compares them within the same stacking context. If a host page element creates a new stacking context (via transform, opacity, filter, will-change, etc.), your widget's container and that element are no longer in the same context.

There is no clean solution. The best you can do:

  1. Attach your container directly to document.body so it's in the root stacking context
  2. Use position: fixed on the container
  3. Document clearly that if a host page uses transform on the <body> element, positioning behavior is undefined

Putting It Together

The full flow:

  1. Append a fixed full-viewport container to document.body with pointerEvents: none
  2. Attach a shadow root to that container
  3. Inject all CSS as plain strings via <style> elements
  4. Mount your Preact app into a <div> inside the shadow root
  5. Manage open/close state by toggling pointerEvents
  6. Handle focus trapping with a keydown listener on document
  7. Accept that z-index is a political problem, not a technical one

The result is a widget that coexists cleanly with any host page. Host page CSS cannot touch your widget. Your widget CSS cannot touch the host page.


IssueCapture uses this architecture to run a bug-reporting modal on thousands of different sites. If you want to see the patterns in context, the widget is at issuecapture.com.

If you've found a clean workaround for the stacking context problem that doesn't require the host page to change their CSS, I'd genuinely like to hear it. I haven't found one.

Source: dev.to

arrow_back Back to Tutorials