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.
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).
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.
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.
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
bodywithout a host. Page CSS styles the button, and SPAs that replacebodydelete it. - Using an open shadow root. Page scripts can then reach in and read or modify your UI. Use
closedunless you need test access. - Relying on
z-indexalone. 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
adoptedStyleSheetsfrom 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
- 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.
- Add
button { all: unset; font: 30px serif !important; }to a page via DevTools and confirm your button is unaffected. - Tab through the page and confirm the button receives a visible focus ring and activates with Enter and Space.
- Drag the button, reload, and confirm it reappears where you left it on that site only.
- 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.
Related
- Keeping injected UI above the page with the top layer — when z-index is not enough.
- Removing injected UI cleanly — teardown for the button and its listeners.
- Injecting UI with shadow DOM without breaking the page — shadow DOM fundamentals.
- In-page overlays and injected UI — the parent topic.