DEV Community

Emir Mrkaljević
Emir Mrkaljević

Posted on

Why your browser extension UI looks broken on half the web (and how a small Shadow DOM wrapper fixes it)

When you inject a small badge or overlay into a third-party website from a browser extension content script, it works fine on your test page. Then you open a real shop and your badge is giant, transparent, stuck in the bottom-left corner, or missing its close button.

I hit this on Monoprix while testing ShrinkWatch EU, an open-source extension that shows unit prices (per kg, per litre or per piece) and shrinkflation warnings on European supermarket web shops.

What the host page does to your DOM

Supermarket frontends are ten years of CSS resets, utility classes and aggressive global selectors piled on top of each other:

  • div { position: relative } or div { box-sizing: content-box }
  • button { all: unset } or button { width: 100%; background: green !important }
  • span { display: block; color: inherit }
  • Global font-size: 62.5% on html, which shrinks every rem-sized rule in your stylesheet

Even if you prefix every class name with sw-, bare element rules (div, span, button) and inherited properties (font, line-height, color, text-align, letter-spacing) still leak into your widget.

The fix: an isolated Shadow Root with :host { all: initial }

Instead of appending your badge directly to document.body and loading a content-script CSS file into the page, create a host element, reset the host itself, and put both the styles and the markup inside a shadow root:

const BADGE_CSS = ':host{all:initial}' +
  '.box{position:fixed;right:16px;bottom:16px;z-index:2147483647;max-width:320px;' +
  'padding:10px 30px 10px 12px;background:#fff;color:#111;border:2px solid #2e7d32;' +
  'border-radius:10px;font:14px/1.4 system-ui,sans-serif;' +
  'box-shadow:0 4px 16px rgba(0,0,0,.2);text-align:left}' +
  '.box.sw-alert{border-color:#c62828;background:#fff5f5}' +
  'div{margin:0}b{font-weight:700;color:#111}span{color:#555}' +
  'button{position:absolute;top:4px;right:6px;border:0;padding:0 4px;' +
  'background:none;font:18px/1 system-ui,sans-serif;cursor:pointer;color:#555}';

function badgeBox() {
  let host = document.getElementById('shrinkwatch-badge');
  if (host && host.shadowRoot) return host.shadowRoot.querySelector('.box');
  if (host) host.remove();
  host = document.createElement('div');
  host.id = 'shrinkwatch-badge';
  host.style.setProperty('all', 'initial', 'important');
  host.style.setProperty('display', 'block', 'important');
  const root = host.attachShadow({ mode: 'open' });
  const style = document.createElement('style');
  style.textContent = BADGE_CSS;
  const box = document.createElement('div');
  box.className = 'box';
  box.setAttribute('role', 'status');
  root.append(style, box);
  document.documentElement.appendChild(host);
  return box;
}
Enter fullscreen mode Exit fullscreen mode

Three details that matter

  1. Both :host { all: initial } and inline all: initial !important on the host element. :host stops inherited properties (font, color, line-height) at the shadow boundary. Setting all: initial !important and display: block !important on the outer <div id="shrinkwatch-badge"> stops the page's own #id or body > div rules from hiding or moving the host.
  2. Use px, never rem. Inside a shadow root, 1rem still equals the host page's root <html> font size. If a shop sets html { font-size: 10px }, 0.875rem becomes 8.75px. Fixed px values look the same on every shop.
  3. Append to document.documentElement, not document.body. Some single-page shops replace or transform document.body during navigation or modal opening (transform on body breaks position: fixed descendants). Hanging the host off <html> keeps position: fixed anchored to the viewport.

Since switching to this wrapper in v0.3.2, the badge has looked the same on every shop checked in a real browser so far.

This article was written with AI assistance. The code, tests and examples come from the repo.

Top comments (0)