DEV Community

[CS] Alishopping
[CS] Alishopping

Posted on

I spent 6 months building a Chrome extension with Vue 3 and Shadow DOM -- here are the hard parts

Your Chrome extension looks perfect in development. The fonts are crisp, the layout is clean, Tailwind utilities work exactly as expected.

Then you deploy it to a real e-commerce site. Every style breaks. The host page has an aggressive CSS reset that overrides your carefully crafted UI. Your text-sm renders at the wrong size. Your flex containers collapse. You add !important to a few rules, then a few dozen, and eventually you realize you are fighting a war you cannot win.

I spent six months building a Chrome extension that injects analysis panels into e-commerce pages. It runs on multiple platforms across thousands of different CSS environments, each one capable of destroying my UI. Here is how I solved it.

The approaches that failed

Before landing on the right solution, I tried three common approaches:

Approach What it does Why it fails for content scripts
Scoped CSS Prevents your styles from leaking out Does not stop host page styles from leaking in
CSS-in-JS Generates unique class names at runtime Runtime overhead, and the host page cascade still applies
iframe True CSS isolation Communication with the host page becomes painful. No shared state. Heavy.

None of these give you bidirectional CSS isolation without significant tradeoffs.

Shadow DOM + adoptedStyleSheets

Shadow DOM creates a hard boundary. The host page's CSS cannot cross into the shadow root. Your CSS cannot cross out. And with adoptedStyleSheets, you can load your stylesheets synchronously -- no flash of unstyled content, no <style> tag injection.

The core helper is simple:

// Generic example: creating a CSSStyleSheet for Shadow DOM
function createSheet(css: string): CSSStyleSheet {
  const sheet = new CSSStyleSheet();
  sheet.replaceSync(css);
  return sheet;
}
Enter fullscreen mode Exit fullscreen mode

In a Vite build, you can import CSS files with the ?inline suffix, which gives you the CSS as a raw string instead of injecting it into the document head. This pairs perfectly with adoptedStyleSheets -- you get build-time CSS processing (PostCSS, Tailwind, Sass) and runtime Shadow DOM isolation.

How the mount pattern works

The general approach for injecting a framework app inside Shadow DOM follows this pattern:

// Generic example: mounting a framework app inside Shadow DOM
function injectExtensionUI() {
  // 1. Create a host element in the light DOM
  const host = document.createElement('div');
  host.style.cssText =
    'position:fixed;top:0;right:0;z-index:2147483647;' +
    'width:0;height:0;overflow:visible;pointer-events:none;';
  document.body.appendChild(host);

  // 2. Attach a shadow root
  const shadow = host.attachShadow({ mode: 'open' });

  // 3. Load stylesheets in the correct cascade order
  shadow.adoptedStyleSheets = [
    createSheet(resetCSS),
    createSheet(utilityCSS),
    createSheet(componentCSS),
    createSheet(themeCSS),
  ];

  // 4. Create a mount point and hand it to your framework
  const mountEl = document.createElement('div');
  mountEl.style.pointerEvents = 'auto';
  shadow.appendChild(mountEl);

  // 5. Mount your framework (Vue, React, Svelte, etc.)
  const app = createApp(RootComponent);
  app.mount(mountEl);
}
Enter fullscreen mode Exit fullscreen mode

This is framework-agnostic. The Shadow DOM setup is identical whether you use Vue, React, Svelte, or vanilla JS. The framework-specific part is only the last few lines.

The details that took debugging to get right

z-index: 2147483647. That is the maximum 32-bit signed integer. It ensures the extension panel sits above every element on the host page, including modals and fixed headers.

pointer-events: none on the host, auto on the mount point. The host element covers the viewport corner, but clicks pass straight through it to the page below. Only the actual extension UI captures mouse events. Without this, users cannot click on page elements near the extension panel.

width:0; height:0; overflow:visible. The host element takes zero layout space. The extension UI overflows visually but does not push page content around.

Guard against double-injection. Content scripts can reload when Chrome updates or when navigating within a SPA. Without a guard check (e.g., checking for an existing host element by ID), you get duplicate panels stacking on top of each other.

CSS ordering matters

The order of stylesheets in adoptedStyleSheets is not arbitrary. Later sheets have higher specificity, following the standard CSS cascade:

  1. Reset CSS -- Normalizes the shadow root (box-sizing, font defaults)
  2. Utility CSS (e.g., Tailwind) -- Utility classes as the base layer
  3. Component CSS -- Panel layout, component-specific styles
  4. Theme CSS -- Colors, dark mode variables, theming overrides

If you put the theme before the component styles, theme variables get overridden by more specific selectors. If you put utilities after components, utility classes override your custom layout. The order is: normalize, then utilities, then components, then theme.

Supporting multiple platforms with the same pattern

We use this identical architecture across different e-commerce platforms, with different positioning for each:

  • One platform gets a floating side panel on the right
  • Another gets a bottom toolbar with a glass-blur effect

Each platform gets its own component CSS and theme CSS, but the shadow root setup, the createSheet() helper, and the framework mounting sequence are identical. Adding a new platform means writing a short mount script and two CSS files -- the architecture scales cleanly.

Note that adoptedStyleSheets and attachShadow are supported in all Chromium browsers. Since we are building a Chrome extension, cross-browser compatibility is not a concern here.

Beyond CSS: other hard parts

CSS isolation was the biggest challenge, but not the only one.

Cross-origin requests in MV3. Content scripts cannot make cross-origin fetch requests. We had to build a message-passing layer where the content script asks the background service worker to make requests on its behalf. The service worker has the necessary permissions and returns the response through the messaging channel. I go deeper into this in a later article in this series.

Client-side scoring. The extension runs multiple scoring algorithms entirely in the browser. Each one is a pure TypeScript function -- no network calls, no ML model, no backend. A product analysis completes in milliseconds. The design of these scoring engines is the topic of the next article.

Trusted Types compatibility. Some e-commerce sites enforce strict Content Security Policy with Trusted Types. Injecting DOM elements through Shadow DOM requires creating a custom Trusted Types policy to avoid CSP violations. This was a particularly frustrating issue to debug because the errors only appear on specific sites.

What I would do differently

Looking back after six months:

  1. Start with Shadow DOM from day one. I wasted three months trying scoped CSS and increasingly desperate !important chains before switching. The migration was painful because styles were already deeply coupled to the assumption that they lived in the main document.

  2. Use strict TypeScript from the start. We migrated gradually (non-strict mode, allowing JS files) which means we still have legacy files mixed in. Starting strict would have caught dozens of bugs earlier.

  3. Build a typed messaging layer from the start. Our background script started with raw string-based message types. A properly typed request/response system would have prevented several runtime errors where the content script sent a message the background script did not expect.


If you are building a Chrome extension that injects UI into third-party pages, Shadow DOM with adoptedStyleSheets is the approach I would recommend without hesitation. It is the only solution that gives you true bidirectional CSS isolation, zero runtime overhead, and full framework support.

What is your approach to CSS isolation in content scripts? I would be curious to hear what has worked (or not worked) for you.

Try it free:
https://chromewebstore.google.com/detail/alishopping-%E2%80%94-aliexpress/agiaehdeifaihlndhnhcmopjpjijnfap?utm_source=devto

Top comments (0)