Why Content Scripts Break Every Website (And How Shadow DOM Fixes It)

Content scripts are one of the most powerful parts of a browser extension. They run inside the user's active tab and can read, modify, or augment any page the user visits. The problem is tha

  • Jenny Wilson Jenny Wilson
  • date icon

    Thursday, Mar 12, 2026

Why Content Scripts Break Every Website (And How Shadow DOM Fixes It)

Content scripts are one of the most powerful parts of a browser extension. They run inside the user’s active tab and can read, modify, or augment any page the user visits. The problem is that they share a CSS environment with the host page — and that sharing is almost always catastrophic for your UI.

Here is what happens, why it happens, and how to build content script UIs that look correct on every website, every time.

The CSS Bleeding Problem

When your content script appends a DOM element to the page, that element inherits the page’s CSS. Every global style rule, every wildcard reset, every * { box-sizing: border-box } declaration — all of it applies to your UI.

On some pages this is invisible. On others it is dramatic: your button’s font is overridden, your modal’s z-index conflicts with the site’s nav bar, your layout collapses because the host page resets padding to 0 globally.

The inverse problem is just as bad. If your extension injects a stylesheet — say, Tailwind CSS or any component library — those styles leak out into the host page. A Tailwind reset.css that normalizes h1 styles will affect every h1 on the page, not just the ones inside your extension UI.

This produces support tickets that are nearly impossible to reproduce: “The extension breaks on [specific site].” The site has unusual CSS. Your UI breaks on it in a way it does not break on the 20 sites you tested during development.

Why a Scoped Stylesheet Is Not Enough

The instinct is to scope all your CSS under a unique class: .lightningaddon-widget h2 { ... }. This helps but does not fully solve the problem. It protects your styles from leaking out, but it does not protect your elements from inheriting the host page’s styles. If the host page sets *, *::before { font-family: 'Comic Sans' !important }, your extension widget will render in Comic Sans regardless of your scoped styles.

CSS Modules, styled-components, and emotion all have the same limitation — they scope your styles, but they cannot prevent external styles from being applied to your elements.

The Shadow DOM Solution

The Shadow DOM is a browser-native encapsulation boundary. A shadow root attached to an element creates a scoped DOM subtree with its own style scope. Styles defined inside a shadow root do not affect the outside document. Styles in the outside document do not affect elements inside the shadow root.

This is exactly the isolation content scripts need.

// Create the host element
const container = document.createElement('div')
document.body.appendChild(container)

// Attach a shadow root in 'open' mode
const shadowRoot = container.attachShadow({ mode: 'open' })

// Everything rendered inside shadowRoot is isolated
const appDiv = document.createElement('div')
shadowRoot.appendChild(appDiv)

// Inject your stylesheet inside the shadow root
const style = document.createElement('style')
style.textContent = YOUR_CSS_STRING
shadowRoot.appendChild(style)

// Mount React inside the shadow root
ReactDOM.createRoot(appDiv).render(<App />)

Now your React component tree lives inside the shadow root. Host page styles cannot reach it. Your styles cannot leak out.

Tailwind Inside Shadow DOM

Tailwind is the natural choice for extension UI — utility-first classes, small output, no external dependencies. But Tailwind generates a stylesheet that needs to live inside the shadow root, not in the document <head>.

The correct approach is to inject the Tailwind output as an inline <style> tag inside the shadow root at mount time:

import tailwindStyles from './styles.css?inline'

const style = document.createElement('style')
style.textContent = tailwindStyles
shadowRoot.appendChild(style)

The ?inline import in Vite returns the CSS file as a string instead of injecting it into the document. You then inject it yourself into the shadow root. Tailwind classes defined in this stylesheet apply only to elements inside the shadow root.

This is the pattern LightningAddon ships in the in-page app. The shadow root is created and managed in a utility in @repo/browser-utils, the Tailwind output is injected inline, and the React tree mounts inside the isolated root. It works correctly on every website we have tested, including sites with aggressive CSS resets.

Positioning and Z-Index

Shadow DOM handles CSS isolation — it does not handle positioning relative to the host page. Your content script UI still needs to position itself correctly on the page, which means positioning the container element (the shadow host) in the regular document.

A few patterns that work well:

Fixed overlay: Set position: fixed on the shadow host element. This positions the widget relative to the viewport, not the page. Works well for toolbars, notification panels, or persistent side panels.

container.style.cssText = `
  position: fixed;
  top: 0;
  right: 0;
  width: 320px;
  height: 100vh;
  z-index: 2147483647;
  pointer-events: none;
`

The z-index 2147483647 is the maximum 32-bit integer — it will sit above almost every site element. Set pointer-events: none on the container and pointer-events: auto on the actual UI elements inside the shadow root, so the host page remains interactive where your widget is not visible.

Injected inline: For annotations or overlays that need to appear next to specific page elements, inject the shadow host as a sibling of the target element. Your styles are still isolated; the positioning is handled by the host element’s placement in the DOM.

A Note on mode: 'open' vs mode: 'closed'

attachShadow({ mode: 'open' }) means page JavaScript can access the shadow root via element.shadowRoot. mode: 'closed' returns null from that property, making the internals harder to access from page scripts.

For most extension UIs, open mode is fine. The “security” benefit of closed mode is minimal — a determined page script can still reach your shadow root through other means. Use open mode; it is easier to debug.

What You Get

Shadow DOM isolation means:

  • Your UI renders correctly on every website without site-specific CSS bug reports
  • Injecting Tailwind does not affect the host page’s typography or layout
  • Host page CSS resets, font overrides, and z-index stacking contexts cannot affect your widget
  • You can iterate on your UI independently without worrying about which sites you are breaking

It does add a small amount of setup complexity. That setup is done once at project start and never touched again. LightningAddon ships it already working in the in-page app — the shadow root, Tailwind injection, and React mounting are all implemented in @repo/browser-utils and ready to use from the first pnpm build:chrome.

Blog

Read More Posts

Your Trusted Partner in Data Protection with Cutting-Edge Solutions for
Comprehensive Data Security.

How to Get Your First 100 Paying Users for a Browser Extension
date icon

Tuesday, Mar 17, 2026

How to Get Your First 100 Paying Users for a Browser Extension

Most browser extension SaaS products never reach 100 paying users — not because they are bad products, but because the d

Read More
Supabase vs Firebase for Your Extension Backend: A Practical 2026 Comparison
date icon

Sunday, Mar 15, 2026

Supabase vs Firebase for Your Extension Backend: A Practical 2026 Comparison

If you are starting a browser extension SaaS, you need a backend for auth, data storage, and Stripe webhook handling. Su

Read More
How to Update Your Extension Without Breaking Existing Users
date icon

Saturday, Mar 14, 2026

How to Update Your Extension Without Breaking Existing Users

Browser extensions store data in chrome.storage.local and chrome.storage.sync. Unlike a server-side database, you do

Read More
LightningAddon Chrome, Firefox & Safari extension framework call to action

Stop Rebuilding the Same Foundation. Start Shipping.

Auth, billing, multi-browser builds, typed messaging — every extension SaaS needs the same boring foundation. LightningAddon ships all of it, battle-tested and ready in 15 minutes. Pay once, own it forever.

Get LightningAddon