Removing Injected CSS with removeCSS
Undo styles injected with chrome.scripting.insertCSS: exact-match rules for removeCSS, keeping CSS in one constant, toggling themes per tab, frames and origins, and what happens after navigation and reloads.
Table of Contents
A “dark reader” toggle, a focus mode that hides distractions, a highlight style for search results — all inject CSS into the page with chrome.scripting.insertCSS, and all need to take it away again. chrome.scripting.removeCSS exists for exactly that, and it is unforgiving: it removes a stylesheet only if you describe it exactly as it was inserted — same CSS string or files, same target, same origin. Change one character in a template literal between insert and remove and nothing happens, without any error. This guide shows how to make CSS toggles reliable. It belongs to scripting API and dynamic injection.
How inserted styles are identified
insertCSS adds a stylesheet to the target document at the “user” or “author” origin level; it is not a <style> element in the DOM, so page scripts cannot see or remove it and your content script cannot either. The browser keeps a record of each inserted stylesheet keyed by its content — the exact css string or the list of files — plus the target and origin. removeCSS looks for a record with an identical key and removes it. There is no handle or id returned from insertCSS to pass back, so the only way to remove a stylesheet is to reproduce its key precisely. Inserting the same CSS twice creates two records, and one removeCSS removes only one of them.
Step-by-step: reliable CSS toggles
1. Keep the CSS in one constant or file
1// styles.js
2export const FOCUS_CSS = `
3 aside, [role="complementary"], .sidebar, .ad, .newsletter-signup { display: none !important; }
4 article, main { max-width: 72ch !important; margin-inline: auto !important; }
5`;
Execution context: a shared module imported by the worker. The same string object is used for insertion and removal, so they can never drift. Generating CSS dynamically — for example from a user-chosen font size — is fine as long as you store the exact generated string per tab and reuse it for removal. Using files instead of css avoids the problem entirely, because the file list is the key.
2. Insert and remember what you inserted
1// sw.js
2import { FOCUS_CSS } from "./styles.js";
3
4export async function enableFocus(tabId) {
5 const key = `css:${tabId}`;
6 const { [key]: applied = [] } = await chrome.storage.session.get(key);
7 if (applied.includes("focus")) return; // avoid duplicate inserts
8 await chrome.scripting.insertCSS({ target: { tabId, allFrames: false }, css: FOCUS_CSS, origin: "USER" });
9 await chrome.storage.session.set({ [key]: [...applied, "focus"] });
10}
Execution context: the service worker, with scripting permission and host access to the tab. Recording which named styles are active per tab prevents double insertion and survives worker eviction. origin: "USER" makes the stylesheet a user stylesheet, which wins over author !important rules in the cascade — useful for overrides that must stick; "AUTHOR" (the default) behaves like a page stylesheet.
3. Remove with the identical description
1export async function disableFocus(tabId) {
2 await chrome.scripting.removeCSS({ target: { tabId, allFrames: false }, css: FOCUS_CSS, origin: "USER" });
3 const key = `css:${tabId}`;
4 const { [key]: applied = [] } = await chrome.storage.session.get(key);
5 await chrome.storage.session.set({ [key]: applied.filter((s) => s !== "focus") });
6}
Execution context: the service worker. The target, CSS and origin mirror the insert exactly. removeCSS resolves successfully even when nothing matched, so your bookkeeping — not the API — is the source of truth for whether a style is active. Firefox and Safari implement removeCSS with the same exact-match semantics.
4. Re-apply after navigation
1chrome.tabs.onUpdated.addListener(async (tabId, change) => {
2 if (change.status !== "loading") return;
3 const key = `css:${tabId}`;
4 const { [key]: applied = [] } = await chrome.storage.session.get(key);
5 if (applied.includes("focus")) {
6 await chrome.scripting.insertCSS({ target: { tabId }, css: FOCUS_CSS, origin: "USER" }).catch(() => {});
7 }
8});
9chrome.tabs.onRemoved.addListener((tabId) => chrome.storage.session.remove(`css:${tabId}`));
Execution context: the service worker. Inserted stylesheets belong to a document; when the tab navigates, the new document starts without them. If the toggle is meant to persist across pages in the tab, re-insert on navigation. Inserting at loading minimises the flash of unstyled content; the .catch covers navigations to pages the extension cannot touch. If the style should apply on every page of a site regardless of tab, a registered content script with a css entry is simpler.
5. Toggle per frame when needed
1await chrome.scripting.insertCSS({ target: { tabId, frameIds: [frameId] }, css: FOCUS_CSS });
2await chrome.scripting.removeCSS({ target: { tabId, frameIds: [frameId] }, css: FOCUS_CSS });
Execution context: the service worker. CSS inserted into a specific frame must be removed from that frame. Inserting with allFrames: true and removing with the top frame only leaves the styles in every iframe. Keep the target object in your record if it varies.
6. Prefer classes for many small variations
1// Insert one stylesheet once, then toggle classes from a content script
2export const THEME_CSS = `
3 html.readable-dark { filter: invert(1) hue-rotate(180deg); }
4 html.readable-dark img, html.readable-dark video { filter: invert(1) hue-rotate(180deg); }
5`;
6// content.js: document.documentElement.classList.toggle("readable-dark", on);
Execution context: the worker inserts the stylesheet once; a content script toggles a namespaced class. When a feature has many states — font sizes, themes, contrast levels — inserting and removing a different stylesheet for each is error-prone. One stylesheet keyed by classes, toggled in the DOM, is easier: removal is a class removal, and the stylesheet itself can stay. Prefix class names to avoid colliding with the page’s own.
7. Clean up when the extension stops
On extension update or disable, inserted stylesheets remain until the page navigates. If that would leave pages broken — a hidden sidebar the user can no longer restore — have the new version’s onInstalled handler remove known stylesheets from open tabs, or reload affected tabs after asking.
Common mistakes
- Rebuilding the CSS string for removal. Any whitespace difference makes
removeCSSa no-op. - Mismatched
origin. Inserted asUSER, removed with the defaultAUTHOR: nothing happens. - Duplicate inserts. Two inserts need two removals; track state to avoid duplicates.
- Expecting styles to survive navigation. They are per document; re-apply.
- Trying to remove from a content script. The stylesheet is not in the DOM; only
removeCSScan remove it.
Cross-browser variation
- Chrome / Edge:
insertCSS/removeCSSwithoriginand frame targeting; no-op on mismatch. - Firefox: same semantics;
cssOriginnaming in the oldertabs.insertCSSAPI,origininscripting. - Safari: supports both calls; test
origin: "USER"behaviour, which has differed in some versions.
Verification
- Toggle focus mode on and off several times on one page and confirm the page returns exactly to its original appearance.
- Navigate within the tab while focus mode is on and confirm it re-applies.
- Change one character in the removal CSS in a test build and confirm removal fails — proof that the constant matters.
- Close the tab and confirm the
css:<tabId>record is removed.
FAQ
Can I list which stylesheets are inserted?
No. Keep your own record.
Does insertCSS work on pages with strict CSP?
Yes. Extension-inserted stylesheets are not subject to the page’s style-src policy.
Is there a limit on inserted stylesheets?
No practical limit, but each insert has a cost; prefer one stylesheet with class toggles.
Should the toggle state follow the user across devices?
The preference can live in chrome.storage.sync (“focus mode on by default”); the per-tab record of what is currently inserted belongs in chrome.storage.session, because it describes documents that exist only in this browser session.
Related
- Injecting CSS and JS with executeScript — the insert side.
- Avoiding CSS conflicts between extension and page — when to use a shadow root instead.
- Detecting tab URL changes — triggers for re-applying.
- Scripting API and dynamic injection — the parent topic.