The pattern
Every content template uses the same shape:src/content/scripts.js
data-extension-rootmarks the host so Extension.js can find it.all: initial !importantprotects the host element itself. A shadow root shields its descendants, not the host. Without that line, a page rule such asdiv { opacity: .8 }fades the widget.- The returned function removes the host. Extension.js calls it before it mounts the next version.
The host element that Extension.js looks for
Extension.js finds your host with one selector:data-extension-root attribute or the extension-root id. There is no class-based form. The value of the attribute is free, so "true" is a convention, not a requirement.
The value extension-js-devtools is reserved. The bundled developer overlay claims it, and the selector excludes it so the two never adopt each other’s roots.
While you develop, Extension.js stamps bookkeeping attributes onto your host:
Those attributes are removed from production builds. Do not write selectors against them.
Getting CSS into the shadow root
A shadow root ignores stylesheets that live outside it. That single rule explains every case below.CSS that you import into the script
Import a stylesheet from a content script and Extension.js inlines it as adata: URL rather than emitting a link. Fetch it and put the text into a <style> element inside the shadow root:
src/content/scripts.js
url() inside that stylesheet is rewritten at build time so it resolves against the extension, not against the page.
CSS that you never insert
When a stylesheet ends up in the bundle and no code inserts it, Extension.js hydrates it into your shadow root for you. It inserts a<style data-extjs-bundle-css="true"> element as the first child of the root.
That help is conditional. As soon as the shadow root holds a <style> element of your own with text in it, Extension.js removes its element and steps back. Your own stylesheet wins.
CSS declared in manifest.json
A stylesheet listed undercontent_scripts[].css is injected by the browser, into the page document. It never reaches a shadow root.
Use manifest CSS to style the page itself. Use imported CSS for anything inside your shadow root.
Web fonts
A@font-face rule inside a shadow root never applies. Font faces resolve against the document, not the shadow tree. Register the face on document.fonts instead. Read the worked example in CSS, Sass, and Less.
Reload behavior
Extension.js reloads a content script by injecting the whole bundle again. It does not swap modules inside a live page. The sequence on each save is:- Extension.js calls the cleanup function that your previous mount returned.
- It removes hosts that carry this script’s owner token from an older build.
- It runs your default export again, which builds a new host.
- It refreshes any stylesheet that it had hydrated.
Multiple entries on one page
Each file in acontent_scripts block gets its own reinject key. One script’s cleanup therefore disposes its own host, never a sibling’s.
Give each entry its own host element. Two entries that share one host fight over cleanup.
Templates
Every template in the Content scripts group mounts into a shadow root:content, content-css-modules, content-custom-font, content-env, content-less, content-less-modules, content-main-world, content-multi-one-entry, content-multi-three-entries, content-preact, content-react, content-sass, content-sass-modules, content-svelte, content-typescript, content-vue
Scaffold the React one to see a framework root inside a shadow root:
src/content/scripts.jsx
Best practices
- Always return a cleanup function that removes the host.
- Keep
all: initial !importanton the host element. - Choose
mode: "open"unless you have a reason to hide the tree. Closed roots are harder to debug. - Query inside
shadowRoot, never insidedocument, for your own nodes. - Do not depend on
data-extjs-*attributes. They exist for the dev loop only.
Next steps
- Read the full content script contract.
- Learn how styles are routed in CSS, Sass, and Less.
- Review reload and HMR behavior.

