Comparing a build against its design usually goes one of two ways. You put the mockup and the browser side by side and eyeball it, which catches layout mistakes and misses everything under about eight pixels. Or you screenshot the page, drop it into the design tool, and toggle visibility, which is accurate but so slow that you do it once and never again.
The third option is to put the design on top of the live page, in the browser, at 50% opacity. Nudge it into position and the drift becomes obvious. Switch the compositing to difference and every pixel that matches turns black, so the only thing left on screen is what is wrong.

That mechanic is a handful of CSS properties. Making it work on arbitrary third-party pages without breaking them is where the actual engineering is. This post covers the parts that were not obvious when I built Pixel Perfect Advanced, an MV3 overlay extension, and most of them generalize to any extension that injects visual UI into pages it does not control.
Disclosure: Pixel Perfect Advanced is our own product, built by the same people who run this blog. Everything below is a report on building it, so read the closing recommendation with that in mind.
The stacking context trap
The obvious structure for a multi-layer overlay is a container element holding one child per layer. Create one wrapper, append your layers, position the wrapper over the page, done.
That structure cannot work, and the reason is worth internalizing because it bites well outside extensions.
mix-blend-mode blends an element with its backdrop, and the backdrop is defined by the nearest stacking context. Almost everything you would naturally put on a full-page wrapper creates one: a z-index on a positioned element, opacity below 1, transform, filter, isolation: isolate, will-change on any of those. The moment the wrapper creates a stacking context, its children can only blend against each other. The page underneath is no longer in scope, and difference compares your design layers to a transparent void instead of to the site.
So there is no wrapper. Every layer is its own top-level host element appended straight to document.documentElement:
// Each layer gets its own top-level host element appended directly to `root`.
// There is deliberately no single wrapper element: a wrapper would create a
// stacking context that isolates mix-blend-mode from the page, but per-layer
// hosts need to blend against the page itself.
export class OverlayRenderer {
private readonly root: HTMLElement;
private readonly hosts = new Map<string, HTMLElement>();tsOrdering then has to be maintained by hand across siblings you do not own. sync() runs on hot paths, including pointermove while a layer is being dragged, so it re-appends only when DOM order actually diverges from layer order, and it tracks position relative to the previous host rather than root.firstChild so that <head> and <body> are never touched:
let prevHost: HTMLElement | null = null;
for (const layer of layers) {
let host = this.hosts.get(layer.id);
// ...
if (prevHost) {
if (host.previousSibling !== prevHost) {
this.root.insertBefore(host, prevHost.nextSibling);
}
} else if (host.parentNode !== this.root) {
this.root.appendChild(host);
}
prevHost = host;
}tsUnconditional appendChild here is a real performance bug, not a theoretical one. Re-appending a node that is already in the right place still triggers removal and insertion, and doing that per layer per pointer event is enough to make dragging feel heavy on a long page.
Difference is the mode that does the work
Five compositing options ship in the picker, and only one of them is a genuine comparison tool.
difference subtracts the two layers channel by channel. Identical pixels produce zero, which is black. Anything non-black is a mismatch, and the brightness tells you how far off it is. You stop reading the screen for design and start reading it for glow. A one-pixel baseline shift on body text lights up as a row of thin bright edges that is impossible to miss and nearly impossible to see at 50% opacity.
multiply and overlay are useful for checking colour and contrast relationships. normal at reduced opacity is what you want for coarse positioning, before you have anything close enough for difference to be readable.
invert is the odd one, because it is not a CSS blend mode at all:
host.style.mixBlendMode =
layer.blend === 'normal' || layer.blend === 'invert' ? 'normal' : layer.blend;
// 'invert' blend renders as a color-inverted layer with normal compositing;
// layer.invert is the legacy per-layer toggle, still honored for saved state.
host.style.filter = layer.invert || layer.blend === 'invert' ? 'invert(1)' : 'none';tsIt sits in the same picker as the four real blend modes because that is where users look for it, but it composites normally and applies filter: invert(1). Presenting it as a peer of difference in the UI while implementing it as a filter is a small deliberate lie, and the alternative, a separate toggle nobody finds, is worse.
Click-through without pointer-events juggling
An overlay sitting above the page will eat every click unless you do something about it. The usual something is toggling pointer-events between none and auto depending on whether the layer is locked, which means the page is periodically not interactive and you get to debug why a click did nothing.
Hosts here are permanently pointer-events: none. They never intercept anything, ever. Interaction is resolved by hit-testing coordinates in the content script instead:
// Hosts are pointer-events: none so the page stays usable; find the
// topmost layer host under given viewport coordinates instead.
function layerHostAt(x: number, y: number): HTMLElement | null {
const hosts = Array.from(document.querySelectorAll<HTMLElement>('ppa-layer')).reverse();
for (const h of hosts) {
const r = h.getBoundingClientRect();
if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return h;
}
return null;
}tsThe list is reversed because later siblings paint on top, so the topmost hit is the last match in DOM order.
Alt becomes the modal key. Alt and drag moves a layer, Alt and wheel scales it, arrow keys nudge. Without Alt the page behaves exactly as if nothing were injected, because as far as the page's event handling is concerned, nothing is. There is no locked state to get stuck in and no mode to forget you are in.
Custom element names help here too. Hosts are <ppa-layer> rather than <div class="ppa-layer">, which makes the selector cheap, makes them obvious in DevTools, and keeps them clear of site CSS that targets div broadly.
Do not take scrolling off its fast path
Scaling with Alt and wheel needs preventDefault(), which means the listener cannot be passive. A non-passive wheel listener in the capture phase forces the browser to wait for JavaScript before it can scroll. Register one of those on document for the lifetime of the page and you have made every page the user visits scroll worse, in exchange for a feature they use on a handful of them.
The listener is therefore attached only while there is something to interact with:
function ensureWheelListener(s: SiteState | null) {
const hasUnlocked = !!s && s.layers.some((l) => !l.locked && l.visible);
if (hasUnlocked && !wheelAttached) {
document.addEventListener('wheel', onWheel, { capture: true, passive: false });
wheelAttached = true;
} else if (!hasUnlocked && wheelAttached) {
document.removeEventListener('wheel', onWheel, { capture: true });
wheelAttached = false;
}
}tsCalled on every state change, so the moment the last layer is hidden or locked the listener comes off and native scrolling gets its fast path back. On a page with no layers, the content script is loaded but has effectively zero cost.
This is the general shape of the problem for any content script: your extension runs on every page, and the pages where it does nothing are the vast majority. Cost on those pages should round to zero.
Persisted state is untrusted input
Layers are stored per origin, under local:site:<origin>, and restored on load. This is the feature that makes the tool usable day to day, since the design you were checking yesterday is still in place when you come back.
It is also the thing most likely to break the extension permanently, and in a way that looks unrelated. Storage outlives your code. A schema change, a half-completed write, or a record from three versions ago will eventually come back, and the content script reads it during startup:
// Persisted data is untrusted: a legacy schema or partial write must never
// reach consumers, because the content script's main() dies on the first
// `state.layers.some(...)` and takes the overlay, storage watcher and message
// handlers down with it for the whole site. Malformed state reads as null.
function asValidSiteState(value: unknown): SiteState | null {
if (typeof value !== 'object' || value === null) return null;
const s = value as { origin?: unknown; layers?: unknown };
if (typeof s.origin !== 'string' || !Array.isArray(s.layers)) return null;
if (!s.layers.every((l) => typeof l === 'object' && l !== null)) return null;
return value as SiteState;
}tsThe failure mode without that guard is nasty. One bad record throws inside main(), so the overlay never renders, the storage watcher is never registered, and the message handlers are never installed. The panel then appears completely dead on that one site and works everywhere else, with nothing in the page console because the exception happened in an isolated world during startup. Reading malformed state as null degrades to "your layers are gone on this site", which is recoverable and self-evident.
The same validator runs on the watcher path, not just the initial read, because a write from another tab arrives through exactly the same door.
The MV3 service worker will forget things mid-session
The extension can resize the viewport to a target width using chrome.debugger and Emulation.setDeviceMetricsOverride, which is how you compare a mobile design without dragging the window to approximately the right size.
Attaching a debugger session is state that lives outside your worker, and the MV3 service worker is not guaranteed to live long enough to remember it:
// Last known emulated width per tab, used only to show the value in the panel.
// It is a best-effort cache: an MV3 service-worker suspend wipes it while the
// debugger session survives, so attach/clear must NOT trust it - they
// reconcile against chrome.debugger.getTargets() (the real source of truth).
const emulated = new Map<number, number>();
async function isAttached(dbg: DebuggerApi, tabId: number): Promise<boolean> {
try {
const targets = await dbg.getTargets();
return targets.some((t) => t.tabId === tabId && t.attached);
} catch {
return false;
}
}tsTrust the in-memory map and the sequence is: user emulates a width, worker suspends, map is empty, user clicks clear, code sees no attachment and returns early. The tab is left with a permanent metrics override and the yellow debugging banner, and the only fix the user can find is closing the tab. Any MV3 extension holding a handle to something the browser owns has this problem. The in-memory copy is a cache for display, never the source of truth.
The related trap is the detach listener. Register it once at startup, not per attach, or you leak a listener on every emulate and clear cycle.
There is also a permissions conversation to have here, and it is worth having openly rather than burying it. chrome.debugger is a broad permission and users are right to look twice at any extension asking for it. Two things are worth saying plainly in a store listing:
// Viewport emulation ("Window to layer" without resizing the window)
// needs the debugger API. Chrome refuses `debugger` as an optional
// permission (it is silently stripped from optional_permissions), so it
// must be required on Chromium. Firefox has no such API.
permissions: browser === 'firefox' ? ['storage'] : ['storage', 'debugger'],tsIt cannot be optional. Chrome lists debugger among the permissions that cannot be requested as optional, so it is either required at install time or the feature does not exist. And it is genuinely per-target: Firefox has no equivalent API, so the Firefox build does not request it at all.
If you ship something like this, name the single feature it powers, and expect to be asked anyway.
One config, three manifests
Chromium targets build to MV3, Firefox to MV2. WXT generates each from a single config function that receives the target browser, rewriting action to browser_action and the side panel to sidebar_action on the Firefox build.
The divergences that actually cost time were the ones that are not mechanical:
permissions: browser === 'firefox' ? ['storage'] : ['storage', 'debugger'],
web_accessible_resources:
browser === 'firefox'
? ['sidepanel.html', 'chunks/*', 'assets/*']
: [{ resources: ['sidepanel.html', 'chunks/*', 'assets/*'], matches: ['<all_urls>'] }],tsweb_accessible_resources takes an object form with matches in MV3 and a flat array in MV2, and the floating panel needs it either way because it loads the side panel into an in-page iframe. Firefox additionally wants browser_specific_settings.gecko with an explicit id, a strict_min_version, and a data_collection_permissions declaration. Because the permission sets genuinely differ per target, any code path touching chrome.debugger needs a real capability check rather than a browser sniff.
If you take one process lesson from this: build every target in CI from the first commit. The divergences are individually small and collectively enough to sink a release if you discover them the week you planned to ship.
What this design costs
None of the individual pieces here are difficult. The overlay is CSS, the click-through is a bounding-box test, the persistence is a key-value store. What makes it work is the accumulation of decisions about not damaging pages you do not control. Those decisions are not free either, and the bill is worth stating:
- No wrapper means manual sibling ordering. Z-order is maintained by hand across nodes appended to
documentElement, on a code path that runs duringpointermove. A wrapper would have made this the browser's problem. Blend modes are the only reason not to have one. - Hit-testing bounding boxes is not hit-testing.
layerHostAtcompares rectangles, so it does not respect transformed or rotated layers, and it will claim a point inside a layer's box but outside its visible shape. For rectangular design layers that is exact. For anything else it would be wrong. - Alt as the modal key collides. It is the menu key on Windows, and window managers on Linux commonly bind Alt-drag to move-window. There is no modifier that is free everywhere.
chrome.debuggercosts installs. It shows "Access the page debugger backend" in the install prompt and a yellow banner while attached. That is a real conversion cost for one feature, it cannot be made optional, and a reasonable person may decline over it.- Per-origin state means per-origin surprises. Layers restoring automatically is the feature; layers restoring on a page you forgot about, weeks later, is the same feature.
If you want the tool rather than the write-up, it is Pixel Perfect Advanced, for Chrome or Firefox. It is ours, as stated at the top.

For more on getting the browser itself out of your way while you work, see The Developer's Browser Workflow.
Sources
Checked 2026-08-20.
- MDN:
mix-blend-modeand MDN: stacking context - that blending is against the backdrop within the nearest stacking context, and the full list of properties that create one. - MDN:
pointer-events-noneas the way to make an element non-interactive without removing it. - MDN:
addEventListener- passive listeners and why a non-passive wheel handler blocks scrolling. - Chrome:
chrome.permissions- thatdebuggeris among the permissions that cannot be requested as optional. - Chrome:
chrome.debugger- the API used for viewport emulation, and the warnings it triggers. - Chrome: extension service worker lifecycle - that an MV3 service worker is terminated between events, which is why in-memory state cannot be trusted.
- MDN:
web_accessible_resources- the MV2 array form versus the MV3 object form. - WXT - the build tool generating the per-browser manifests.