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.
Jenny Wilson