In-Page Overlays & Injected UI

Build UI inside web pages from an MV3 content script: shadow DOM isolation, floating buttons, highlights, tooltips at the selection, modal dialogs in the top layer, and removing everything cleanly.

The popup and side panel are the extension’s own territory. Many of the most useful extension features, though, live on the page itself: a floating “save” button next to an image, highlights over search matches, a translation tooltip beside the selected text, a modal that asks for confirmation before the page submits a form. Building UI inside someone else’s page is a different discipline from building the popup. The page’s CSS reaches into your elements, its z-index stack buries them, its own scripts react to your DOM changes, its layout shifts under your positioned elements, and when the extension updates or is disabled, everything you injected is left behind as debris. This topic sits inside UI/UX Patterns & Interactive Components and starts from the technique that makes all of it manageable: isolating your UI in a shadow root, as in building a floating action button on web pages.

The patterns here share one architecture: a single host element per feature, a closed shadow root that holds every node and style you add, the top layer for anything that must sit above the page, and a teardown function that removes all of it — elements, listeners, observers — on demand.

Layers of an injected UIThe page's own DOM and CSS, the extension's host element, a closed shadow root with its own styles, and the top layer used by popovers and modal dialogs above everything.Top layerpopover · dialog.showModal()above every z-indexShadow root (closed)your nodes + your stylespage CSS cannot reachHost element<readable-ui> in the pageone per featurePage DOM and CSSnot yoursnever restyle it globally
The shadow root keeps page CSS out; the top layer keeps page z-index from mattering.

Prerequisites checklist

  • A content script registered for the pages where the UI appears, or injected on demand with chrome.scripting.
  • Host access to those pages — declared, optional, or via activeTab.
  • A bundler setup that can import CSS as a string, so styles can be placed inside the shadow root.
  • A naming convention for host elements that cannot collide with the page’s (a custom element name with your prefix).
  • A teardown function for every feature that adds DOM, listeners or observers.
  • Accessibility requirements: keyboard reachability, focus management, and labels for every control.

Manifest registration

 1{
 2  "manifest_version": 3,
 3  "permissions": ["storage", "activeTab", "scripting"],
 4  "optional_host_permissions": ["https://*/*"],
 5  "content_scripts": [{
 6    "matches": ["https://*.example.com/*"],
 7    "js": ["overlay.js"],
 8    "run_at": "document_idle"            // the page's own layout has settled
 9  }],
10  "web_accessible_resources": [{
11    "resources": ["fonts/inter-var.woff2"],
12    "matches": ["https://*.example.com/*"],
13    "use_dynamic_url": true              // only if the UI loads files by URL
14  }]
15}

Execution context: parsed at install. Styles bundled into the content script as strings need no web-accessible entry; only files loaded by URL — fonts, images — do. document_idle avoids measuring layout before the page has finished building it. Firefox and Safari accept the same declarations; Safari prompts per site the first time the content script would run.

1. Isolating UI in a shadow root

Without isolation, the page’s stylesheet styles your buttons — button { border-radius: 0; font: 11px serif; } on a news site will apply to yours — and your stylesheet may restyle the page. A shadow root solves both: styles inside it apply only inside it, and page selectors cannot match nodes within it. Inherited properties such as font-family and color still cross the boundary, so reset them at the root.

 1// overlay.js
 2import css from "./overlay.css?inline";      // bundler returns the file as a string
 3
 4export function mountHost(tag = "readable-ui") {
 5  const host = document.createElement(tag);
 6  host.style.cssText = "all: initial; position: fixed; inset: auto 16px 16px auto; z-index: 2147483647;";
 7  const root = host.attachShadow({ mode: "closed" });
 8  const style = document.createElement("style");
 9  style.textContent = `:host { all: initial; } ${css}`;
10  root.append(style);
11  document.documentElement.append(host);
12  return { host, root };
13}

Execution context: the content script’s isolated world, which shares the DOM with the page but not its JavaScript globals. A closed shadow root hides the internals from page scripts that call element.shadowRoot. Appending to document.documentElement rather than body survives pages that replace body during client-side navigation. Custom element names need a hyphen; the element works without a registered class.

What crosses the shadow boundaryWhether page selectors, page inherited properties, CSS custom properties, page scripts and keyboard events reach into a closed shadow root, and how to handle each.From the pageCrosses?Handle withSelectors (button {…})NoNothing neededInherited props (font, color)Yes:host { all: initial }CSS custom propertiesYesPrefix your ownelement.shadowRootNo (closed)mode: closedKey and click eventsBubble outstopPropagation where needed
Selectors stop at the boundary; inheritance and events do not.

2. Floating controls and highlights

The two most common injected elements are a persistent control — a floating button, a small toolbar — and decorations on the page’s own content, such as highlighted matches. Floating controls belong in a fixed-position host in the corner; decorations should not modify the page’s DOM at all when it can be avoided. The CSS Custom Highlight API paints ranges of text without wrapping them in elements, which keeps the page’s DOM intact and its scripts undisturbed.

 1// highlight matches without touching the page's DOM
 2export function highlightAll(term) {
 3  const ranges = [];
 4  const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
 5  for (let n = walker.nextNode(); n; n = walker.nextNode()) {
 6    const i = n.data.toLowerCase().indexOf(term.toLowerCase());
 7    if (i >= 0) { const r = new Range(); r.setStart(n, i); r.setEnd(n, i + term.length); ranges.push(r); }
 8  }
 9  CSS.highlights.set("readable-match", new Highlight(...ranges));
10}

Execution context: the content script. The highlight’s appearance comes from a ::highlight(readable-match) rule, which must be in a stylesheet that applies to the page’s document — inject it with chrome.scripting.insertCSS or a manifest css entry, scoped by the unique highlight name. Chrome 105+ and Safari 17.2+ support the API; recent Firefox versions do as well. Fall back to wrapping text in <mark> elements only where it is missing. Details are in building a floating action button on web pages and highlighting text and elements on a page.

3. Tooltips and popovers anchored to the page

A tooltip next to the user’s selection — a definition, a translation, a “save quote” button — has to find the selection’s position, place itself without leaving the viewport, follow scrolling, and dismiss itself when the user clicks elsewhere or presses Escape. The Popover API provides the dismissal and the top-layer stacking for free; positioning is your job.

 1export function showAtSelection(root, content) {
 2  const sel = getSelection();
 3  if (!sel || sel.isCollapsed) return;
 4  const rect = sel.getRangeAt(0).getBoundingClientRect();
 5  const tip = root.querySelector("#tip") ?? Object.assign(document.createElement("div"), { id: "tip" });
 6  tip.setAttribute("popover", "auto");                  // light-dismiss + Escape
 7  tip.textContent = content;
 8  root.append(tip);
 9  tip.showPopover();
10  const { width, height } = tip.getBoundingClientRect();
11  const left = Math.min(Math.max(8, rect.left + rect.width / 2 - width / 2), innerWidth - width - 8);
12  const top = rect.top - height - 8 >= 8 ? rect.top - height - 8 : rect.bottom + 8;
13  Object.assign(tip.style, { position: "fixed", left: `${left}px`, top: `${top}px`, margin: "0" });
14}

Execution context: the content script, with root being your shadow root. Popovers inside a shadow root are promoted to the top layer like any other, so the page’s z-index: 99999 header cannot cover them. popover="auto" closes on outside click and Escape. Chrome 114+, Safari 17+ and Firefox 125+ support popovers; feature-detect HTMLElement.prototype.showPopover. See positioning tooltips near a text selection.

From selection to tooltipThe user selects text; the content script reads the selection range's rectangle, asks the worker for the content, shows a popover in the shadow root, positions it within the viewport, and the browser dismisses it on outside click.UserContent scriptService workerselects text (mouseup)range.getBoundingClientRect()define(term)definitionshowPopover() + clamp to viewportclick outside → auto-dismiss
The browser handles stacking and dismissal; you handle content and position.

4. Modal dialogs over any page

Some actions deserve the user’s full attention: confirming a destructive operation, a one-time consent, a sign-in step. A <dialog> opened with showModal() sits in the top layer, makes the rest of the page inert, traps focus, and closes on Escape — all native behaviours that are hard to replicate with positioned divs on a page you do not control.

 1export function confirmDialog(root, message) {
 2  return new Promise((resolve) => {
 3    const dlg = document.createElement("dialog");
 4    dlg.innerHTML = `<form method="dialog"><p></p><menu><button value="cancel">Cancel</button><button value="ok" autofocus>Continue</button></menu></form>`;
 5    dlg.querySelector("p").textContent = message;
 6    dlg.addEventListener("close", () => { resolve(dlg.returnValue === "ok"); dlg.remove(); }, { once: true });
 7    root.append(dlg);
 8    dlg.showModal();
 9  });
10}

Execution context: the content script, rendering into the shadow root. The static markup is a fixed template and the message is inserted with textContent, so page data cannot inject markup. ::backdrop styles come from the shadow root’s stylesheet. Native modal dialogs are supported in all three engines. Focus handling and scroll locking are covered in showing a modal dialog over any page and stacking in keeping injected UI above the page with the top layer.

5. Tearing everything down

Injected UI outlives the code that created it. When the extension updates or is disabled, Chrome stops the old content script’s connection to the extension — chrome.runtime calls start throwing “Extension context invalidated” — but leaves its DOM, listeners and observers in place. The new content script injects a second copy. Users see duplicate buttons and dead controls until they reload the page.

 1export function createFeature() {
 2  const ctrl = new AbortController();
 3  const { host, root } = mountHost();
 4  const observer = new MutationObserver(onMutations);
 5  observer.observe(document.body, { childList: true, subtree: true });
 6  document.addEventListener("selectionchange", onSelection, { signal: ctrl.signal });
 7
 8  return function teardown() {
 9    ctrl.abort();                    // every listener registered with this signal
10    observer.disconnect();
11    CSS.highlights?.delete("readable-match");
12    host.remove();
13  };
14}

Execution context: the content script. Registering every listener with one AbortController signal makes teardown a single call. Trigger it when the extension asks (a message), when the user turns the feature off, and when the context is invalidated — detected by chrome.runtime.id becoming undefined. A newly injected script should also remove any host left by a previous instance before mounting its own. The full pattern is in removing injected UI cleanly.

Lifecycle of one injected featureRemove any leftover host from a previous instance, mount a new host and shadow root, register listeners with an abort signal, observe the page, then tear everything down on disable, update or invalidation.Remove stale hostfrom old instanceMount hostclosed shadow rootListen + observeone AbortSignaldisable · update · context invalidatedctrl.abort()all listenersobserver.disconnect()stop watchinghost.remove()no debris
Every mount has exactly one teardown, and every new instance cleans up after the last.

6. Coexisting with single-page apps

Modern sites rebuild large parts of their DOM without a page load. A React or Vue app may replace the subtree your button was anchored to, re-render the article you highlighted, or swap body entirely on navigation. Injected UI must assume its surroundings can vanish at any moment and recover without a reload.

1// Re-attach the host if the page removed it, and re-run decorations after big DOM changes
2const keepAlive = new MutationObserver(() => {
3  if (!host.isConnected) document.documentElement.append(host);
4  scheduleRehighlight();                       // debounced; ranges die with their nodes
5});
6keepAlive.observe(document.documentElement, { childList: true, subtree: true });
7
8addEventListener("popstate", scheduleRehighlight, { signal: ctrl.signal });

Execution context: the content script. Watching documentElement catches the host being removed by a framework that clears body. Highlight ranges and anchored positions refer to specific nodes; when those nodes are replaced, the ranges collapse and must be recomputed, so debounce a re-run after mutation bursts rather than reacting to each one. Client-side navigation does not reload content scripts, so URL-dependent features should also listen for history changes, as described in handling single-page app navigation in content scripts.

Keep observers narrow where you can. Observing the whole document with subtree: true on a busy app can deliver thousands of records per second; filter quickly and do real work only after the burst settles. The cost of a sloppy observer is page jank that users will attribute to the site — or, once they disable extensions to test, to you. Measuring that cost is covered in observing DOM changes efficiently with MutationObserver.

Cross-cutting concerns: privacy, security and accessibility

Injected UI runs in pages that may be hostile. Never insert page-derived strings with innerHTML; use textContent or build elements. Never trust events that appear to come from your UI — a page script can dispatch synthetic clicks on your host element, and event.isTrusted is the only reliable signal of a real user action. A closed shadow root hides your nodes from casual inspection, but a determined page can still detect your host element’s presence; do not put secrets in the DOM.

Accessibility is easy to forget on someone else’s page. Every injected control needs an accessible name, keyboard access, and a visible focus style that survives the page’s outline: none reset — which the shadow root’s own styles provide. Modal dialogs must return focus to where it was when they close. And injected UI must respect the user’s preferences: prefers-reduced-motion for animations and forced-colors for high-contrast modes, as covered in respecting reduced motion and forced colors.

Privacy matters too: an overlay that appears on every page reveals to the page that the extension is installed, and anything your UI renders — a saved note, a username, a count of items — is readable by the page’s scripts if it lands in the light DOM or in an open shadow root. Show UI only when the user invokes it or on the sites where the feature is relevant.

Deciding whether UI belongs on the page at all

Injected UI costs more than any other surface: it must survive hostile CSS, coexist with the page’s own overlays, respect the page’s focus and scroll, and disappear completely when asked. Before building it, check whether the popup, side panel or a context menu item would serve the user as well. Injected UI earns its place when it must sit next to specific page content — a highlight, a tooltip on a selection, a button attached to a form field — and not when it simply needs somewhere to display information.

MV3 constraints box

  • Content scripts only. Injected UI lives in content scripts; the service worker cannot touch the DOM.
  • No remote code. Templates and styles must ship in the package; no CDN-hosted UI kits.
  • Context invalidation. After an update, old content scripts lose chrome.runtime but keep their DOM; tear down on detection.
  • Shadow DOM does not isolate events or inheritance. Reset inherited styles at :host and manage event propagation deliberately.
  • Page CSP can block resources. A strict page CSP may block extension fonts or images loaded by URL in some engines; inline styles in the shadow root are not affected.
  • Maximum z-index is not enough. Pages also use the top layer; your popovers and dialogs share it, last opened on top.

Cross-browser notes

CapabilityChrome / EdgeFirefoxSafari
Closed shadow roots in content scriptsYesYesYes
Popover API114+125+17+
dialog.showModal()YesYesYes
CSS Custom Highlight API105+Recent versions17.2+
adoptedStyleSheets from content scriptsYesLimited by Xray wrappers; use <style>Yes
Context invalidation signalchrome.runtime.id undefinedSimilar; scripts often unloadedVaries

Firefox’s Xray vision — the wrapper that isolates content scripts from page objects — makes some APIs behave differently when objects cross worlds. Constructable stylesheets created in the content script cannot always be adopted into a shadow root; a <style> element is the portable choice.

What this section covers

The guides follow the order a feature usually grows in: building a floating action button on web pages, highlighting text and elements on a page, positioning tooltips near a text selection, showing a modal dialog over any page, keeping injected UI above the page with the top layer and removing injected UI cleanly.

Covered elsewhere: how content scripts are injected and isolated is in content scripts and DOM injection, and UI that lives in the browser’s own chrome is in side panel and DevTools interfaces.