Avoiding CSS Conflicts Between Extension and Page
Stop page styles breaking extension UI and extension styles breaking pages: shadow DOM isolation, all: initial resets, prefixed classes, CSS layers, user-origin stylesheets, custom properties and font loading.
Table of Contents
- How styles leak in both directions
- Step-by-step: choose the right isolation
- 1. Default to a shadow root for any injected UI
- 2. Reset the host element itself
- 3. Scope stylesheets that must style the page
- 4. Choose user origin for overrides that must stick
- 5. Theme with your own custom properties
- 6. Load fonts without depending on the page
- 7. Use an iframe when isolation must be total
- Common mistakes
- Cross-browser variation
- Verification
- FAQ
- Related
Two complaints arrive in the same week. One user says the extension’s panel looks broken on their company intranet — giant serif text, buttons with no padding. Another says that since installing the extension, the “Submit” button on their bank’s site is blue and misaligned. CSS is global by default: the page’s rules apply to everything in its document, including elements your content script adds, and any stylesheet your content script injects applies to the page’s own elements. Preventing conflicts in both directions takes a deliberate isolation strategy. This guide lays out the options from weakest to strongest and when each is appropriate. It belongs to content scripts and DOM injection.
How styles leak in both directions
Inbound leakage — page CSS affecting your UI — happens through three channels. Selectors: the page’s button, div > span, [class*="icon"] rules match your elements. Inheritance: properties like font, color, line-height and letter-spacing flow from the page’s elements into yours. Global resets: * { box-sizing: content-box } or img { max-width: 100% }. Outbound leakage — your CSS affecting the page — happens when stylesheets injected through the manifest’s css key or insertCSS use generic selectors: a .button or .hidden rule in your stylesheet styles every matching element on every site. Each technique below closes some channels and leaves others open.
Step-by-step: choose the right isolation
1. Default to a shadow root for any injected UI
1// content.js
2import css from "./panel.css?inline";
3const host = document.createElement("readable-panel");
4const root = host.attachShadow({ mode: "closed" });
5root.innerHTML = `<style>:host{all:initial} ${css}</style><div class="panel"></div>`;
6document.documentElement.append(host);
Execution context: the content script in the isolated world. A shadow root blocks page selectors from matching your nodes and keeps your rules from matching the page’s. :host { all: initial } resets inherited properties at the boundary so the page’s font and colour do not flow in. Styles inside the shadow root are scoped automatically, so you can use short class names. This is the right default for panels, buttons, tooltips and dialogs — see injecting UI with shadow DOM without breaking the page.
2. Reset the host element itself
1host.style.cssText = "all: initial; position: fixed; z-index: 2147483646; inset: auto 16px 16px auto;";
Execution context: the content script. The host element lives in the page’s light DOM, so page rules like body > * { display: block; margin: 1em } or readable-panel { display: none } (if a site blocks known extension elements) can target it. Inline styles with all: initial override most of them; for hostile pages, add !important to the properties you rely on.
3. Scope stylesheets that must style the page
1/* injected with insertCSS — styles applied to the PAGE on purpose */
2@layer readable {
3 html.readable-focus aside,
4 html.readable-focus [role="complementary"] { display: none !important; }
5 html.readable-focus .readable-hl { background: #fde68a; color: #111827; }
6}
Execution context: a stylesheet inserted into the page document by the service worker. Some features exist to change the page — hiding clutter, highlighting — and cannot live in a shadow root. Gate every rule behind a namespaced class on <html> that your content script toggles, so the rules apply only while the feature is on; prefix every class you introduce (readable-…); and wrap the rules in a cascade layer. Unlayered page styles beat layered styles of the same importance, which makes your non-!important rules yield to the page’s when you want them to. Use !important only on rules that must win.
4. Choose user origin for overrides that must stick
1await chrome.scripting.insertCSS({ target: { tabId }, css: FOCUS_CSS, origin: "USER" });
Execution context: the service worker. User-origin stylesheets sit in a different cascade origin: for !important declarations, user styles beat author styles regardless of specificity. That makes origin: "USER" the reliable choice for accessibility overrides — minimum font size, forced contrast — that pages should not be able to defeat. Pair it with the exact-match removal rules in removing injected CSS with removeCSS.
5. Theme with your own custom properties
1/* inside the shadow root */
2:host { --rd-bg: #ffffff; --rd-fg: #111827; --rd-accent: #2563eb; }
3@media (prefers-color-scheme: dark) { :host { --rd-bg: #1f2937; --rd-fg: #f9fafb; } }
4.panel { background: var(--rd-bg); color: var(--rd-fg); }
Execution context: the shadow root’s stylesheet. CSS custom properties do inherit through the shadow boundary — all: initial does not reset them — so a page that defines --accent or --bg could influence yours if the names collide. Prefix your variables and define them on :host, which wins over inherited values. Following the OS colour scheme keeps your UI readable on dark sites.
6. Load fonts without depending on the page
1@font-face {
2 font-family: "Readable Inter";
3 src: url("chrome-extension://__MSG_@@extension_id__/fonts/inter-var.woff2") format("woff2");
4}
5:host { font: 14px/1.4 "Readable Inter", system-ui, sans-serif; }
Execution context: a stylesheet in the shadow root. @font-face inside a shadow root is not applied in every engine; declaring it in a small stylesheet inserted into the page document (with a uniquely prefixed family name) is the portable approach, and the font file must be web accessible to that page. Simpler still: use system-ui, which needs no file and matches the OS. The __MSG_@@extension_id__ placeholder only works in CSS files loaded as extension resources; in JavaScript-generated CSS, use chrome.runtime.getURL.
7. Use an iframe when isolation must be total
1const frame = document.createElement("iframe");
2frame.src = chrome.runtime.getURL("panel.html");
3frame.style.cssText = "all: initial; position: fixed; inset: auto 16px 16px auto; width: 360px; height: 480px; border: 0; z-index: 2147483646;";
4document.documentElement.append(frame);
Execution context: the content script, embedding an extension page that must be listed in web_accessible_resources. An iframe is a separate document: no styles cross in either direction, and the page cannot read its DOM. The page can still detect, resize or remove the frame. Use this for complex UI built with a framework and its own stylesheet, at the cost of a postMessage bridge for communication and exposure of the frame’s URL — see declaring web accessible resources correctly.
Common mistakes
- Unprefixed classes in injected stylesheets.
.hiddenor.btnrestyles half the internet. - Shadow root without an inheritance reset. The page’s font still flows in.
- Generic custom property names.
--bgcollides with page variables through the boundary. !importanteverywhere. Use it where you must win, not as a default.- Forgetting the host element. Page rules can still target it in the light DOM.
Cross-browser variation
- Chrome / Edge: shadow DOM,
@layer, andinsertCSSwithorigin: "USER"all supported. - Firefox: same capabilities;
adoptedStyleSheetsfrom content scripts is restricted by Xray wrappers, so use<style>elements in shadow roots. - Safari: shadow DOM and
@layersupported in current versions;@font-faceinside shadow roots has historically been ignored — declare fonts in the page document.
Verification
- Inject a stylesheet like
* { font: 30px serif !important; color: red !important; }into a test page and confirm your UI is unaffected. - Load the extension on a page with classes named like yours and confirm the page’s elements are not restyled.
- Toggle the page into dark mode and confirm your UI follows the OS scheme, not the page’s variables.
- Remove the extension’s page-level stylesheet and confirm the page returns exactly to its original look.
FAQ
Is shadow DOM enough against hostile pages?
It blocks styling. It does not stop a page from removing or hiding your host element. Watch for removal if the UI matters.
Can I use Tailwind in a shadow root?
Yes — build the CSS and insert it into the shadow root as a string. Set Tailwind’s preflight to scope to :host, or disable it and rely on your own reset.
Do content scripts’ CSS files from the manifest go into the shadow root?
No. Manifest css files are injected into the page document. Use them only for page-level styling.
Related
- Injecting UI with shadow DOM without breaking the page — shadow DOM fundamentals.
- Building a floating action button on web pages — the technique applied.
- Theming the options page for dark mode — consistent theming across surfaces.
- Content scripts and DOM injection — the parent topic.