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.

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.
My first instinct was the same instinct I use in normal web apps:
div.document.body.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.
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.
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:
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 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:
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.
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.
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.
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.
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.
After building this, I stopped treating injected UI like regular frontend work. I use a different checklist.
Before shipping an overlay, I ask:
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.
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:
The point is not perfection. The point is not being surprised by the obvious cases after users install it.
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.
Chrome's own docs are worth reading before building this kind of feature:
They explain the pieces. The real learning comes when you connect those pieces to messy webpages and a real product constraint.
Productivity-focused Chrome extensions built for developers and power users.

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....

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...

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...