Building a Floating Action Button on Web Pages

Inject a floating action button from an MV3 content script that the page cannot restyle: closed shadow root, fixed positioning, safe areas, drag to reposition, keyboard access and per-site hiding.

Published October 2, 2026 Updated October 2, 2026 9 min read
Table of Contents

You want a small button in the corner of every article page — “Save”, “Summarise”, “Read aloud” — that is always within reach without opening the popup. The first version appends a <button> to body with some inline styles, and on half the sites you test it looks wrong: the page’s CSS gives it a serif font and a 2-pixel border, a sticky footer covers it, a cookie banner sits on top of it, and on one site clicking it submits a form. A floating action button on arbitrary pages needs isolation, careful positioning and deliberate event handling. This guide builds one. It belongs to in-page overlays and injected UI.

Why the naive button breaks

A plain element appended to the page is a full citizen of the page’s style system. Every selector that matches button, body > *, or [class*="btn"] applies to it; every inherited property — font, colour, line height, letter spacing, text transform — flows into it; and the page’s stacking contexts decide whether it is visible. Events from it bubble through the page’s handlers, so a page that listens for clicks on document to close menus, or delegates form submission from a parent, reacts to your button too. Isolation has to address all three channels: selectors (a shadow root stops them), inheritance (all: initial resets it) and events (stop propagation where the page would misinterpret them).

Anatomy of an isolated floating buttonA fixed-position host element reset with all initial, a closed shadow root containing a style element and the button, and event handling that stops propagation into the page.Host <readable-fab>position: fixed; all: initialstacking + placementClosed shadow rootselectors cannot enterstyle isolation<style> + <button>:host { all: initial }own fonts, own sizingEvent handlingstopPropagation on clickpage handlers untouched
Three layers of defence against a page you did not write.

Step-by-step: an isolated, movable button

1. Create the host and shadow root

 1// fab.js (content script)
 2const TAG = "readable-fab";
 3
 4export function mountFab() {
 5  document.querySelector(TAG)?.remove();            // leftover from a previous instance
 6  const host = document.createElement(TAG);
 7  host.style.cssText = [
 8    "all: initial", "position: fixed", "z-index: 2147483646",
 9    "right: max(16px, env(safe-area-inset-right))",
10    "bottom: max(16px, env(safe-area-inset-bottom))",
11  ].join(";");
12  const root = host.attachShadow({ mode: "closed" });
13  document.documentElement.append(host);
14  return { host, root };
15}

Execution context: the content script’s isolated world. Removing any existing host first prevents duplicates when the script is injected twice — after an extension update, or by both a manifest declaration and a scripting.executeScript call. Inline styles on the host carry the placement because page CSS could otherwise target the host element itself, which is in the light DOM. env(safe-area-inset-*) keeps the button clear of rounded corners and notches on mobile Safari.

2. Style it from inside the shadow root

 1const CSS = `
 2  :host { all: initial; }
 3  button {
 4    all: unset; box-sizing: border-box;
 5    width: 48px; height: 48px; border-radius: 50%;
 6    display: grid; place-items: center; cursor: pointer;
 7    background: #2563eb; color: #fff;
 8    font: 600 14px/1 system-ui, sans-serif;
 9    box-shadow: 0 2px 8px rgb(0 0 0 / .25);
10  }
11  button:focus-visible { outline: 3px solid #f59e0b; outline-offset: 2px; }
12  @media (prefers-reduced-motion: no-preference) { button { transition: transform .15s; } button:hover { transform: scale(1.06); } }
13  @media (forced-colors: active) { button { border: 2px solid ButtonText; } }
14`;
15
16export function renderFab(root, onActivate) {
17  root.innerHTML = `<style>${CSS}</style><button type="button" aria-label="Save this article">★</button>`;
18  root.querySelector("button").addEventListener("click", (e) => {
19    e.stopPropagation();
20    if (e.isTrusted) onActivate();
21  });
22}

Execution context: the content script, writing into its own closed shadow root. The template is a fixed string with no page data, so innerHTML is safe here. all: unset on the button removes the user-agent defaults so only your rules apply; an explicit focus style matters because many sites reset outline globally, and you are not covered by their reset inside the shadow root anyway. isTrusted ignores synthetic clicks a page script might dispatch on your host.

Page interference and the defence for eachFive ways a page interferes with an injected button — selector styles, inherited fonts, stacking, event delegation and synthetic clicks — and the technique that neutralises each.InterferenceDefenceWherebutton { … } rulesShadow rootattachShadowInherited font / colourall: initial / unset:host + buttonCovered by page layersFixed + high z-indexHost inline styleDelegated click handlersstopPropagationButton listenerSynthetic clicksevent.isTrustedButton listener
No single technique covers everything; together they do.

3. Do the work in the worker

The button’s click handler should send intent, not do privileged work itself.

1renderFab(root, async () => {
2  const reply = await chrome.runtime.sendMessage({ type: "save-article", url: location.href, title: document.title });
3  announce(root, reply?.ok ? "Saved" : "Could not save");
4});

Execution context: the content script, messaging the service worker, which holds host permissions for your API and the user’s tokens. Keeping the content script thin limits what a compromised page could do with it. announce updates a visually hidden role="status" element in the shadow root so screen readers hear the result.

4. Let the user move it

A fixed corner will cover something on some site. Let the user drag the button and remember the position per site.

 1function makeDraggable(host, button, siteKey) {
 2  let start = null;
 3  button.addEventListener("pointerdown", (e) => {
 4    start = { x: e.clientX, y: e.clientY, r: parseFloat(host.style.right), b: parseFloat(host.style.bottom), moved: false };
 5    button.setPointerCapture(e.pointerId);
 6  });
 7  button.addEventListener("pointermove", (e) => {
 8    if (!start) return;
 9    const dx = e.clientX - start.x, dy = e.clientY - start.y;
10    if (Math.abs(dx) + Math.abs(dy) > 4) start.moved = true;
11    host.style.right = `${Math.min(innerWidth - 56, Math.max(8, start.r - dx))}px`;
12    host.style.bottom = `${Math.min(innerHeight - 56, Math.max(8, start.b - dy))}px`;
13  });
14  button.addEventListener("pointerup", async () => {
15    if (start?.moved) await chrome.storage.local.set({ [`fab:${siteKey}`]: { right: host.style.right, bottom: host.style.bottom } });
16    start = null;
17  });
18}

Execution context: the content script. Pointer capture keeps the drag working when the pointer leaves the button. A movement threshold separates a drag from a click — suppress the click when moved is true. Positions are clamped to the viewport so the button cannot be dragged off-screen, and stored per hostname so each site keeps its own placement.

A drag that is not a clickPointerdown records the start; pointermove beyond four pixels marks the gesture as a drag and moves the host; pointerup saves the position and the following click is suppressed.UserButtonstorage.localpointerdownpointermove (> 4 px)moved = true; update right/bottompointerupfab:example.com = {right, bottom}click suppressed
The threshold decides whether the user meant to move the button or press it.

5. Hide it where it does not belong

1const site = location.hostname;
2const { hiddenSites = [] } = await chrome.storage.sync.get("hiddenSites");
3if (hiddenSites.includes(site) || !document.querySelector("article, main")) {
4  // do not mount on this page
5} else {
6  const { host, root } = mountFab();
7  renderFab(root, saveArticle);
8}

Execution context: the content script, before mounting. Appearing on every page — search results, web apps, checkout flows — makes a floating button feel like adware. Mount only on pages where the feature applies, and offer “Hide on this site” through a context menu on the action or a long-press menu on the button. Store the list in chrome.storage.sync so it follows the user across devices.

6. Make it keyboard-reachable without stealing focus

The button is in the page’s tab order by default, after everything else on the page. That is usually right: never move focus to it automatically. Offer a keyboard shortcut instead, declared in the manifest’s commands, that focuses or activates the button.

1chrome.runtime.onMessage.addListener((msg) => {
2  if (msg?.type === "fab:focus") root.querySelector("button")?.focus();
3});

Execution context: the content script, receiving a message the service worker sends from chrome.commands.onCommand. Content scripts cannot listen for extension commands directly. See handling shortcuts in content scripts.

Common mistakes

  • Appending to body without a host. Page CSS styles the button, and SPAs that replace body delete it.
  • Using an open shadow root. Page scripts can then reach in and read or modify your UI. Use closed unless you need test access.
  • Relying on z-index alone. A page’s own modal in the top layer still covers you; that is usually correct — do not fight the page’s dialogs.
  • Doing network work in the content script. It runs under the page’s CORS rules and exposes tokens to the page’s process. Message the worker.
  • No way to hide it. Users who cannot dismiss a floating button uninstall the extension.

Cross-browser variation

  • Chrome / Edge: everything here works as written; env(safe-area-inset-*) evaluates to zero on desktop.
  • Firefox: closed shadow roots and pointer capture work; avoid adoptedStyleSheets from the content script, which Firefox’s Xray wrappers can block, and use a <style> element as shown.
  • Safari: works on macOS and iOS; safe-area insets matter on iPhone. Safari prompts for site access the first time the content script runs on a site, so the button may appear only after the user grants it.

Verification

  1. Load three very different sites — a news article, a web app, a site with a sticky footer — and confirm the button looks identical on each.
  2. Add button { all: unset; font: 30px serif !important; } to a page via DevTools and confirm your button is unaffected.
  3. Tab through the page and confirm the button receives a visible focus ring and activates with Enter and Space.
  4. Drag the button, reload, and confirm it reappears where you left it on that site only.
  5. Reload the extension and confirm exactly one button remains on an open page after re-injection.

FAQ

Should the button be a custom element with a registered class?

Not necessary. An unregistered element with a hyphenated name is a valid host. Registering with customElements.define from a content script works in Chrome but has had cross-world quirks in Firefox; the unregistered approach is portable.

How do I avoid covering the page’s own chat widget?

Let the user drag it, remember per-site positions, and start in a corner less commonly used — bottom-left avoids most chat bubbles.

Can I use a framework inside the shadow root?

Yes. Render React, Preact, Vue or Svelte into a container inside the shadow root, and make sure the framework’s styles are injected there rather than into the page’s head.

Other UI/UX Patterns & Interactive Components Resources