Fityra/Blog
ArticlesBuild in PublicDeep Divesfityra.app ↗
Fityra/Blog

Technical deep dives and lessons learned building the Fityra ecosystem.

RSSSitemap

Blog

  • All Articles
  • Build in Public
  • Technical Deep Dives
  • Product Updates

Ecosystem

  • fityra.app ↗
  • SaaSLift ↗
  • Extensions ↗

© 2026 Edson Mark. All rights reserved.

Chrome Extensions

Injecting a UI Overlay Into Every Webpage Without Breaking Any of Them

What I learned building a Chrome extension overlay that can appear on almost any webpage without inheriting broken CSS, stealing clicks forever, or making the page feel unsafe.

9 min read
chrome extensionscontent scriptsshadow dombrowser overlayproduct engineeringmanifest v3
Injecting a UI Overlay Into Every Webpage Without Breaking Any of Them

The first version of my overlay worked beautifully on a blank test page.

That was the trap.

I had a simple reminder panel for a Chrome extension. The extension needed to show a full-page meeting reminder on top of whatever tab I was already using. Not a quiet notification in the corner. Not a calendar popup that disappears behind focus mode. A real interruption, because the whole reason I was building it was that I kept missing meetings while coding.

On my local index.html, it looked clean. Centered panel. Dimmed background. Join button. Snooze button. Done.

Then I tried it on real sites.

One page changed my button font. Another page made the overlay too small. Another had a wild z-index stack that placed a cookie banner above my reminder. One site had CSS rules so broad that my overlay looked like it had been assembled from borrowed parts. Another page had a fixed header that fought with the backdrop.

That was the moment I realized the actual problem was not "how do I render a card?"

The real problem was this:

How do you inject UI into webpages you do not own, without trusting anything about those webpages?

That question shaped the whole build.

The mistake I made first

My first instinct was the same instinct I use in normal web apps:

  1. Create a div.
  2. Append it to document.body.
  3. Add a few class names.
  4. Ship some CSS.

Something like this:

const host = document.createElement('div')
host.id = 'meeting-plane-overlay'
host.className = 'overlay-backdrop'
 
host.innerHTML = `
  <div class="overlay-card">
    <h2>Upcoming meeting</h2>
    <button>Join meeting</button>
  </div>
`
 
document.body.appendChild(host)

This is fine when the page is yours.

Inside a Chrome extension content script, this is naive.

A content script can access the page DOM, but the DOM still belongs to the page. Your injected elements live beside the page's elements. They can inherit page styles. They can be affected by global resets. They can collide with existing IDs, class names, and stacking contexts. The page may also be doing its own aggressive runtime changes.

Chrome's docs describe content scripts as running in an isolated world for JavaScript, which is useful because your variables do not casually collide with page scripts. But that does not mean your DOM is magically protected from CSS. Once you put HTML into the document, you are playing in the page's visual environment.

That difference matters.

I did not fully feel it until I saw my neat little overlay broken by a site that had a rule close to this:

button {
  all: unset;
}

I do not even blame the site. In its own world, that rule may have made sense.

But my extension was now inside that world too.

The fix was isolation, not more CSS

My first reaction was to make the selectors stronger.

That worked for about ten minutes.

I added more specific class names. I prefixed everything. I used longer selectors. I started writing CSS like I was preparing for a courtroom argument:

#meeting-plane-overlay .meeting-plane-card .meeting-plane-button {
  font-family: Inter, system-ui, sans-serif;
}

The overlay improved, but the approach felt wrong. I was not solving the problem. I was trying to win a specificity battle against every website on the internet.

That is not a battle I want to sign up for.

The better answer was the Shadow DOM.

The Shadow DOM gives you a boundary. Styles inside the shadow root stay inside. Most page styles outside the shadow root do not leak in. It is not magic, and it does not remove every edge case, but it changes the problem from "fight the whole page" to "control the small island you created."

The basic mount function became closer to this:

const ROOT_ID = 'meeting-plane-root'
 
export function mountOverlay(meeting: MeetingReminder) {
  if (document.getElementById(ROOT_ID)) return
 
  const host = document.createElement('div')
  host.id = ROOT_ID
  host.style.position = 'fixed'
  host.style.inset = '0'
  host.style.zIndex = '2147483647'
 
  const shadow = host.attachShadow({ mode: 'closed' })
 
  const style = document.createElement('style')
  style.textContent = overlayCss
 
  const root = document.createElement('div')
  root.className = 'overlay'
  root.innerHTML = renderOverlayMarkup(meeting)
 
  shadow.append(style, root)
  document.documentElement.appendChild(host)
}

There are a few choices in that snippet that came from real breakage, not theory.

I append to document.documentElement, not just document.body, because some pages do strange things with the body. I guard against duplicate mounts because extension messages can fire more than once. I set a high z-index on the host because a reminder that appears behind a page modal is not a reminder. I use a shadow root because the overlay needs to keep its own styling.

The first version was clever-looking.

The second version was boring.

Boring was better.

The overlay has to be rude, but only briefly

A meeting reminder is a strange UI component.

It needs to interrupt you. That is the point.

But it cannot behave like malware. It cannot trap the user. It cannot keep covering the page after the user has made a decision. It cannot make the browser feel hijacked.

That line changed how I thought about the overlay.

I started with a full-screen layer because subtle reminders had already failed me. But I made the actions explicit:

  • Join the meeting
  • Snooze
  • Dismiss

The overlay also had to clean itself up completely:

export function unmountOverlay() {
  document.getElementById(ROOT_ID)?.remove()
}

That one line looks too small to be important, but cleanup is a product decision. If an injected overlay leaves behind stale nodes, active listeners, or invisible click blockers, the user does not think "there is a lifecycle bug." They think the extension is sketchy.

That is the thing I kept coming back to while building this:

Browser extensions borrow trust from the browser.

The moment your extension feels too invasive, users start wondering what else it is doing.

So the overlay needed to be strong when it mattered, then disappear cleanly.

The message path became part of the design

The UI is only one piece. The extension also needs to decide when to show it.

In a normal web app, I would keep this state inside React or a backend job. In a Chrome extension, the pieces are split differently:

  • The background service worker handles timing and browser-level events.
  • The content script renders into the current page.
  • Extension storage keeps settings and reminder state.
  • Runtime messages connect the pieces.

A simplified version looks like this:

chrome.alarms.onAlarm.addListener(async (alarm) => {
  if (!alarm.name.startsWith('meeting:')) return
 
  const meeting = await loadMeetingFromStorage(alarm.name)
  if (!meeting) return
 
  const [tab] = await chrome.tabs.query({
    active: true,
    currentWindow: true,
  })
 
  if (!tab?.id) return
 
  await chrome.tabs.sendMessage(tab.id, {
    type: 'SHOW_MEETING_OVERLAY',
    meeting,
  })
})

And the content script listens:

chrome.runtime.onMessage.addListener((message) => {
  if (message.type === 'SHOW_MEETING_OVERLAY') {
    mountOverlay(message.meeting)
  }
 
  if (message.type === 'HIDE_MEETING_OVERLAY') {
    unmountOverlay()
  }
})

This looks simple, but the edge cases are where the real work is.

What if the active tab is a Chrome internal page where content scripts cannot run? What if the tab has not finished loading? What if the user switches tabs after the alarm fires? What if the overlay already exists because a previous message succeeded but the background worker retried?

I learned to treat every message as a request, not a guarantee.

The content script has to be idempotent. The background worker has to tolerate failure. The UI has to recover if it cannot mount.

That sounds overly cautious until you remember where this code runs: on pages you did not build, inside browser behavior you do not fully control, triggered by time-sensitive events the user actually cares about.

Z-index is not a strategy

I used 2147483647 for the host z-index because it is the practical ceiling people often use for overlays.

But I do not like pretending that a giant number is architecture.

The deeper issue is stacking context.

If you inject the overlay inside an element that is already trapped in a stacking context, the big z-index may not help. That is why I prefer mounting the host directly under document.documentElement and setting the host itself to fixed positioning.

The host becomes the top-level layer:

host.style.position = 'fixed'
host.style.inset = '0'
host.style.zIndex = '2147483647'
host.style.pointerEvents = 'auto'

Then the shadow UI controls the inside:

.overlay {
  min-height: 100vh;
  display: grid;
  place-items: center;
  background: rgba(15, 23, 42, 0.38);
}
 
.panel {
  width: min(440px, calc(100vw - 32px));
  border-radius: 20px;
  background: white;
  color: #0f172a;
  box-shadow: 0 24px 80px rgba(15, 23, 42, 0.28);
}

The important part is not the exact CSS. The important part is that I stopped thinking of the overlay as "some elements inside the page" and started thinking of it as a small separate surface with its own lifecycle.

That mental model made the implementation calmer.

CSS resets still matter inside the shadow root

The Shadow DOM helped a lot, but I still added a small reset inside the overlay.

Not a huge reset. Just enough to make the component predictable:

:host {
  all: initial;
  position: fixed;
  inset: 0;
  z-index: 2147483647;
  font-family:
    Inter,
    ui-sans-serif,
    system-ui,
    -apple-system,
    BlinkMacSystemFont,
    "Segoe UI",
    sans-serif;
}
 
* {
  box-sizing: border-box;
}
 
button {
  font: inherit;
}

The :host rule is doing emotional support work here.

It tells the browser: this thing is its own thing.

I still avoid making the overlay too elaborate. The more complicated the UI, the more places it can fail. For this extension, the overlay is not trying to become a dashboard. It is trying to answer one urgent question:

Do you want to join this meeting now?

Everything else is secondary.

I had to make peace with pages where it cannot run

One uncomfortable truth about Chrome extensions: you cannot inject into every surface.

Chrome internal pages, the Chrome Web Store, and some restricted URLs are off limits. Some pages may block behavior in ways you cannot gracefully fix. Some tabs do not have normal page contexts.

This is where product writing matters as much as code.

If the overlay cannot appear, the extension still needs a fallback. For a meeting reminder, that might mean a standard browser notification, an extension badge, or a popup state that says the reminder is active.

I do not think the fallback has to be as powerful as the overlay. If it were, the overlay would not need to exist.

But the user should never feel like the reminder silently failed.

That became one of my design rules:

If the strong path fails, show a weaker signal instead of showing nothing.

The permission story matters too

An overlay extension can easily become scary if the permissions are too broad.

Users are already sensitive about extensions that can read and change data on websites. They should be. A content script is powerful. If I ask for access to too much, I need to earn that request.

So I try to keep the permission explanation plain:

The extension needs to place a reminder overlay on the tab you are already using. It is not trying to inspect your page content for the meeting reminder itself. The overlay exists so the reminder is visible when a normal notification would be missed.

That distinction matters.

Technical people sometimes treat permissions as implementation details. Users treat them as a trust contract.

If a feature needs a permission, the product should explain the feature in human language.

The checklist I use now

After building this, I stopped treating injected UI like regular frontend work. I use a different checklist.

Before shipping an overlay, I ask:

  • Does it mount only once if messages repeat?
  • Does it clean up every DOM node it creates?
  • Does it isolate its styles from the page?
  • Does it avoid relying on page fonts, resets, or layout?
  • Does it still work on pages with fixed headers, cookie banners, and modals?
  • Does it have a fallback when content scripts cannot run?
  • Does it explain its permissions in plain language?
  • Does it feel like a tool, not a takeover?

That last one is subjective, but it is probably the most important.

Because an overlay is a power move.

It says, "Stop what you are doing and look at this."

If you use that power for something trivial, the user will resent it. If you use it for something genuinely time-sensitive, the same interaction can feel helpful.

The difference is not the component.

The difference is judgment.

What I would do differently now

If I were starting again, I would build the overlay harness before designing the final UI.

I would make a test page full of hostile CSS:

* {
  box-sizing: content-box;
}
 
button,
input,
div {
  font-family: Comic Sans MS;
  z-index: 1;
}
 
body {
  transform: translateZ(0);
}

Then I would mount the overlay there first.

Not because real websites are all that extreme, but because hostile test pages reveal assumptions quickly. If the overlay survives the ugly page, it will probably behave better on normal pages.

I would also test with:

  • Long page scroll
  • Fullscreen-looking web apps
  • Pages with modals already open
  • Pages that dynamically replace the body
  • Tiny mobile-width windows
  • High zoom levels
  • Dark mode pages

The point is not perfection. The point is not being surprised by the obvious cases after users install it.

The useful lesson

The biggest lesson was that an injected overlay is not just UI.

It is integration code.

It touches browser APIs, user trust, page isolation, CSS architecture, accessibility, timing, and failure handling. A normal modal only has to behave inside your app. A Chrome extension overlay has to behave inside everyone else's app.

That changes the standard.

I still like the overlay approach. For Meeting Plane Reminder, it fits the job. A meeting reminder should not whisper when the user is deep in code. It should show up clearly, give the user a fast choice, and then get out of the way.

But I respect the problem more now.

The technical trick is not injecting HTML.

Anyone can inject HTML.

The craft is injecting something that feels native enough to trust, isolated enough to survive, and temporary enough that the user never wonders if it is still hanging around after it disappears.

That is the part worth building carefully.

References I kept nearby

Chrome's own docs are worth reading before building this kind of feature:

  • Content scripts
  • Message passing
  • Chrome alarms API

They explain the pieces. The real learning comes when you connect those pieces to messy webpages and a real product constraint.

View Chrome Extensions

Productivity-focused Chrome extensions built for developers and power users.

Browse extensions →
Share
PreviousI've Been Building Alone for Two Years. That Changes Today.NextBuilding an App-Store UI for My Chrome Extensions with Next.js

Related articles

OAuth in a Chrome Extension Is Not the Same as in a Web App
Chrome Extensionschrome extensionsoauth

OAuth in a Chrome Extension Is Not the Same as in a Web App

I underestimated OAuth in a Chrome extension because I had done OAuth in web apps before. That confidence lasted about one afternoon. In a normal web app, the shape is familiar: 1. Send the user to an authorization URL. 2. The provider redirects back to your domain. 3. Your server receives an authorization code. 4....

9 min readRead more
Chrome's history page is a junk drawer. I gave mine a memory instead.
Chrome Extensionsbrowser memorychrome history

Chrome's history page is a junk drawer. I gave mine a memory instead.

Chrome's history page is a junk drawer. I gave mine a memory instead. I have lost the same useful page more times than I want to admit. Not because I forgot how to search. Not because the page disappeared. Usually it was still there, buried somewhere inside , sitting between a login redirect, a docs page I opened by...

9 min readRead more
How I Check Whether a Chrome Extension Is Asking for Too Much Access
Chrome Extensionschrome extensionsextension permissions

How I Check Whether a Chrome Extension Is Asking for Too Much Access

How I Check Whether a Chrome Extension Is Asking for Too Much Access I used to install Chrome extensions the way most people install them: I saw a useful feature, checked a few reviews, clicked install, and moved on. That sounds normal because it is normal. Extensions are supposed to feel lightweight. A screenshot...

10 min readRead more

On this page

  • The mistake I made first
  • The fix was isolation, not more CSS
  • The overlay has to be rude, but only briefly
  • The message path became part of the design
  • Z-index is not a strategy
  • CSS resets still matter inside the shadow root
  • I had to make peace with pages where it cannot run
  • The permission story matters too
  • The checklist I use now
  • What I would do differently now
  • The useful lesson
  • References I kept nearby