Injecting UI with Shadow DOM Without Breaking the Page
Mount extension UI into a host page using a closed shadow root — style isolation both ways, stacking context, event retargeting and surviving the page's own DOM churn.
Table of Contents
Appending a <div> with your own class names to someone else’s page is a bet that their stylesheet does not use those names, that their * { box-sizing } reset will not reshape your buttons, and that their z-index: 2147483647 header will not sit on top of your panel. You will lose that bet on a site you have never heard of, and the bug report will be a screenshot you cannot reproduce. A shadow root removes the bet. This guide is part of content scripts and DOM injection.
What the shadow boundary does and does not stop
Step-by-step
1. Mount a host element that the page will leave alone
1function mountHost() {
2 const existing = document.getElementById("ext-root");
3 if (existing) return existing.shadowRoot;
4
5 const host = document.createElement("div");
6 host.id = "ext-root";
7 // Take the element out of the page's layout entirely.
8 host.style.cssText = "all: initial; position: fixed; inset: auto 16px 16px auto; z-index: 2147483647;";
9 document.documentElement.appendChild(host);
10 return host.attachShadow({ mode: "closed" });
11}
Execution context: the content script’s isolated world. Appending to documentElement rather than body means the mount works at document_start, before body exists. all: initial on the host resets every inherited property in one declaration, which is what stops the page’s font-family and line-height reaching your UI.
2. Prefer a closed shadow root
mode: "closed" means host.shadowRoot returns null to the page. It is not a security boundary — a determined script can still reach in by patching Element.prototype.attachShadow before you run — but it stops the ordinary case where a page’s own code walks the DOM and trips over your subtree.
1const root = mountHost();
2root.innerHTML = `
3 <style>
4 :host { all: initial; }
5 .panel { font: 14px/1.4 system-ui, sans-serif; background: #fff; color: #111;
6 border: 1px solid #d4d4d8; border-radius: 10px; padding: 12px; width: 320px; }
7 @media (prefers-color-scheme: dark) {
8 .panel { background: #18181b; color: #f4f4f5; border-color: #3f3f46; }
9 }
10 </style>
11 <div class="panel"><slot></slot></div>`;
Execution context: the content script. innerHTML inside a shadow root is still subject to the extension’s CSP, not the page’s — a template literal with no interpolated page data is safe, and interpolating page data here is the injection risk covered in sanitising untrusted page data in an extension.
3. Load your stylesheet as a file, not a string
For anything beyond a few rules, ship real CSS and adopt it. adoptedStyleSheets avoids reparsing on every mount and keeps the CSS lintable.
1const sheet = new CSSStyleSheet();
2const css = await fetch(chrome.runtime.getURL("content/panel.css")).then((r) => r.text());
3sheet.replaceSync(css);
4root.adoptedStyleSheets = [sheet];
Execution context: the content script’s isolated world. The CSS file must be listed in web_accessible_resources for the origins you inject into, or the fetch is blocked. CSSStyleSheet construction is available in Chrome, Firefox 101+ and Safari 16.4+.
4. Handle events knowing they are retargeted
An event that crosses the boundary reports the host element as its target, not your button. Inside the shadow root the real target is available through event.composedPath()[0].
1root.addEventListener("click", (e) => {
2 const el = e.composedPath()[0];
3 if (el.matches?.("[data-action='close']")) unmount();
4});
Execution context: the content script. Listening on the root rather than on individual elements means the handler survives re-renders. Note that the page can still see the click on the host element — if the page uses a document-level click handler to close its own menus, your UI will trigger it. e.stopPropagation() on the root prevents that, at the cost of breaking any page behaviour that legitimately depends on the click.
5. Survive the page removing your host
Single-page applications replace large parts of the DOM, and your host element can go with them. Re-mount when it disappears rather than assuming it persists.
1const observer = new MutationObserver(() => {
2 if (!document.getElementById("ext-root")) mountAndRender();
3});
4observer.observe(document.documentElement, { childList: true, subtree: true });
Execution context: the content script. Observing the whole document is expensive on a busy page; scope it to a container where you can, and always disconnect when the feature is turned off. The navigation side of this is covered in handling single-page app navigation in content scripts.
6. Respect the page you are a guest on
Three habits separate a panel users tolerate from one they uninstall over:
- Do not steal focus. Mount without calling
focus()unless the user just invoked you. - Do not lock scrolling. Setting
overflow: hiddenonbodybreaks the page after your panel closes if you ever fail to restore it. - Leave nothing behind. Unmount removes the host, disconnects observers and revokes any object URLs.
1function unmount() {
2 observer.disconnect();
3 document.getElementById("ext-root")?.remove();
4}
Execution context: the content script. Removing the host removes the shadow tree with it; there is no separate teardown needed for the adopted stylesheet.
Accessibility inside a shadow root
A shadow root does not hide your UI from assistive technology — the accessibility tree traverses it normally — but it does break two things that rely on id references crossing the boundary.
aria-labelledby, aria-describedby and for all resolve within a single tree. An element inside the shadow root cannot reference an id in the light DOM, and the page cannot reference yours. In practice this is mostly a constraint on how you build labels, and it is easy to satisfy by keeping each control and its label in the same root.
1root.innerHTML = `
2 <style>/* … */</style>
3 <div class="panel" role="dialog" aria-labelledby="t" aria-modal="false">
4 <h2 id="t">Reading options</h2>
5 <label for="size">Text size</label>
6 <input id="size" type="range" min="12" max="24">
7 </div>`;
Execution context: the content script’s isolated world. Both the id and the reference live inside the shadow root, so they resolve. Note aria-modal="false" — an injected panel is not modal, and claiming otherwise makes screen-reader users unable to reach the page behind it.
Focus behaves as you would hope: document.activeElement from the page reports the host element, while root.activeElement reports the real focused node. A focus trap, if you build one, must query within the root.
1function focusables(r) {
2 return [...r.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')]
3 .filter((el) => !el.hasAttribute("disabled"));
4}
Execution context: the content script. Keep traps rare — a panel the user cannot escape from with the keyboard is worse than one they can tab past — and always honour Escape.
The last consideration is announcement. A panel that appears without notice is invisible to a screen-reader user until they happen to tab into it. A single polite live region inside the root, updated once on mount, is the least intrusive way to say it is there — the technique in announcing dynamic updates to screen readers.
Cross-browser variation
- Chrome / Edge: full support for closed shadow roots,
adoptedStyleSheetsandcomposedPath.all: initialon the host is well supported and is the most reliable single reset. - Firefox: equivalent support;
adoptedStyleSheetsfrom Firefox 101. Firefox applies the user’s font-size preference through inheritance, so relying on it crossing the boundary is correct behaviour rather than a bug to suppress. - Safari: shadow DOM support is complete from Safari 16.4 including constructable stylesheets. Safari’s handling of
position: fixedinside a transformed ancestor differs, so mount ondocumentElementrather than inside page markup. - All three:
z-indexonly competes within a stacking context. A page that puts its header in a transformed container creates one your fixed host cannot escape from if you mounted inside it — another reason to mount at the document root.
Verification
- Confirm the page cannot see into your tree:
1document.getElementById("ext-root").shadowRoot; // null for a closed root
Execution context: the page’s own console (the main world), not the content script’s. A non-null result means the root was opened, or something re-patched attachShadow before you ran.
- Load a site with an aggressive reset — a Tailwind-preflight site is ideal — and confirm your panel’s spacing and fonts are unchanged.
- Navigate within a single-page app and confirm the panel re-mounts rather than vanishing.
- Close the panel and confirm no
ext-rootelement, no observers and no listeners remain.
FAQ
Should the shadow root be open or closed?
Closed, unless you have a specific reason to allow page scripts in. It is not a security boundary, but it removes the whole class of bugs where a page’s DOM walker encounters your subtree.
Why is my panel behind the site’s header?
Almost always a stacking context: a transformed or filtered ancestor traps position: fixed. Mounting on documentElement rather than inside page markup solves it in nearly every case.
Can I use a framework inside the shadow root?
Yes — React, Preact and Svelte all render into a shadow root. Keep the bundle small, because it is parsed into every matching document, and remember the extension’s CSP forbids eval, which rules out runtime template compilers.
Related
- Best practices for content script isolation in MV3 — the JavaScript side of the same boundary.
- Handling single-page app navigation in content scripts — keeping UI alive across soft navigations.
- Sanitising untrusted page data in an extension — what not to interpolate into your markup.
- Content scripts and DOM injection — the parent guide.