DEV Community

Cover image for Overlaying a design on a live page: four things that bit me
ss
ss

Posted on

Overlaying a design on a live page: four things that bit me

I spent the last few weeks building a Chrome extension that puts a design frame on top of a running web page so you can see where the implementation drifted. The idea is old — designers have been dropping semi-transparent mockups over their builds since Photoshop — but doing it inside a browser extension turned out to have a few traps that cost me real time.

None of these are exotic. All four are the kind of thing that looks correct, runs without an error, and produces a subtly wrong result you only notice when you measure something.

  1. An overlay pinned to the viewport is the wrong overlay The obvious way to draw an overlay is position: fixed. It sits where you put it, it doesn't move, done.

It's wrong the moment the user scrolls.

The thing you're comparing against — the page's content — lives in document space. A fixed overlay lives in viewport space. Line them up at the top of the page, scroll down two hundred pixels, and the design has silently slid two hundred pixels relative to the content it's supposed to match. Every alignment you did is gone, and worse, it looks like the page is wrong.

The fix is to stop fighting it and put the overlay in document space:

element.style.position = "absolute";
element.style.top = "0";
element.style.left = "0";
element.style.transform = translate3d(${layer.x}px, ${layer.y}px, 0);
Anchored at the document origin and offset with a transform, the browser scrolls it in step with the content for free. No scroll listener, no repositioning on every frame, no jank.

The related detail: when you create a layer, anchor it to where the user is actually looking, not to the document's absolute top:

x: window.scrollX,
y: window.scrollY,
Otherwise a layer added while scrolled halfway down a long page appears somewhere off-screen and the user thinks nothing happened.

  1. The image is not the size the image says it is This one produced a bug I stared at for a while.

Design exports are routinely 2x or 3x. A 1440-wide frame exported at 2x is a 2880-pixel PNG. If you take naturalWidth and set it as the CSS width — which is the obvious thing to do — the overlay renders at 2880 CSS pixels: exactly twice as large as the design it represents. Every comparison after that is meaningless, and because the overlay is semi-transparent and roughly the right shape, it doesn't look broken. It looks like the page is very wrong.

When you control the export you can divide it out. Fetching from a design API, you asked for the scale, so you know it:

const scale = result.scale || 1;
const width = probe.naturalWidth / scale;
const height = probe.naturalHeight / scale;
For a file the user drops in, you can't know. A PNG carries no record of the scale it was exported at. There's no metadata to read, no heuristic that's right often enough to trust.

There is exactly one case worth detecting automatically: a screenshot taken on a high-density display, which comes back at devicePixelRatio times the CSS size it represents. If the image's width matches this viewport times the device ratio, that's almost certainly what it is:

function autoFitDivisor(naturalWidth) {
const dpr = window.devicePixelRatio || 1;
if (dpr <= 1) return 1;
const expected = document.documentElement.clientWidth * dpr;
const tolerance = Math.max(8, expected * 0.01);
return Math.abs(naturalWidth - expected) <= tolerance ? dpr : 1;
}
Everything else gets left at true size and a visible scale control. Guessing harder would be wrong about as often as it was right, and a silently mis-scaled overlay is worse than one the user has to adjust.

One subtlety worth stealing: keep the source dimensions alongside the displayed ones and always compute from the source. If you scale relative to the current size, repeated adjustments accumulate rounding drift and "100%" stops meaning the original.

  1. Host page CSS will eat your UI Any panel you inject into an arbitrary page is at the mercy of that page's stylesheet. Resets, a global * { box-sizing }, a stray img { opacity: 1 }, another extension's styles — all of it lands on your elements.

A closed shadow root solves it in both directions: their CSS can't reach in, yours can't leak out.

const host = document.createElement("div");
document.documentElement.append(host);
const shadowRoot = host.attachShadow({ mode: "closed" });
But note what that does not cover. The overlay images themselves are deliberately outside the shadow root, appended to document.body, because they need to live in document coordinates (see the first trap). Those are back in the host page's cascade, so anything load-bearing has to be defended explicitly:

element.style.setProperty("opacity", String(layer.opacity / 100), "important");
Without !important there, a host page with an aggressive img rule makes your opacity slider look broken. The user drags it and nothing happens.

Two more things that catch people with shadow-root UI:

Relative asset paths don't work. A path in injected markup resolves against the host page, not your extension, so it 404s. You need chrome.runtime.getURL('icons/thing.png') and the file listed in web_accessible_resources. Both halves are required; missing either gives a broken image with no useful error.

Stacking is on you. Give each layer an explicit z-index derived from your own ordering rather than relying on DOM order, and put isolation: isolate on each so it forms its own stacking context. Otherwise reordering layers in your UI doesn't reorder what's actually painted.

  1. Two storage areas, no warning chrome.storage.sync and chrome.storage.local are entirely separate stores. Write a key to one, read it from the other, and you get undefined. No error, no warning — the read just comes back empty, so it presents as "the save failed."

I lost time to this after a refactor moved a token from one area to the other in the background script but not in the popup that wrote it. The settings screen cheerfully reported success and the feature that needed the token insisted no token was set.

Worth a grep across the whole codebase for chrome.storage. any time you touch persistence, checking every call for the same key uses the same area. It takes a minute and it's the kind of bug that survives code review because each half looks correct on its own.

A note on rate limits
Not a code trap, but it cost me three days, so: if you integrate a design API, log the raw response body and the Retry-After header on failures from the start.

I hit a 429 with Retry-After of about 294,000 seconds — three and a half days. I assumed a bad token and regenerated it. Same block. I assumed a bad file and tried a different one. Same block, with a counter that was ticking down in real clock time across both. The limit was attached to the account, not the token, so regenerating was never going to help, and every retry risked restarting it.

Without the raw body and header logged, all I had was "request failed" and a plausible wrong theory.

The general shape of it
Every one of these produced working code that returned a wrong answer. That's the category that costs the most: an exception tells you where to look, and a silently mis-scaled overlay just makes you distrust the page.

The thing that eventually caught each one was measuring instead of looking — an overlay that looks aligned and one that is aligned are indistinguishable at 60% opacity, which is more or less the reason the tool exists.

All four of these came out of building WideEye, a Chrome extension that drops a design onto a live page and scans for the difference.

Top comments (0)