Masking CSS-Selector Regions for Map Popups
A single open popup can flag an otherwise-identical map as regressed: it carries a live timestamp, a queried feature value, or a close button whose focus ring paints differently between runs, and its tail is anchored to a floating-point screen coordinate that never rounds to the same pixel twice. The fix is not to loosen the diff threshold until the noise disappears — that blinds the gate to real cartographic faults — but to exclude exactly the volatile region and compare everything around it at full sensitivity. This page is the procedure for doing that by CSS selector: enumerate the volatile nodes, paint a stable mask over each one, and prove the mask still covers the element after the viewport changes.
This is one rule set inside Interactive Overlay Masking Rules, the guide that defines how a masking target binds to a strategy and a lifecycle anchor. It sits under the parent reference on the tension between deterministic rendering and non-deterministic chrome, Dynamic Element Masking & UI Stability. Where the sibling recipe on how to mask dynamic user cursors and tooltips in map tests handles pointer-driven overlays, this one focuses on selector-addressable boxes — popups, attribution, and live readouts — and the geometry problem their coordinate anchoring creates.
Prerequisites
Step-by-step procedure
1. Enumerate the volatile selectors
Before masking anything, inventory every node that legitimately varies between two correct runs. Do not mask stable chrome — a masked region is a region you stop testing. The recurring offenders across libraries are the popup container, the tooltip box, the attribution control, and any element that renders a clock or a live coordinate. Record them in the manifest with the reason each is volatile, so a reviewer can audit what the gate no longer sees.
// masking-manifest.js — volatile selectors, grouped by why they vary.
export const volatileSelectors = {
popups: ['.maplibregl-popup', '.mapboxgl-popup', '.leaflet-popup'],
tooltips: ['.maplibregl-tooltip', '.leaflet-tooltip', '.ol-tooltip'],
attribution: ['.maplibregl-ctrl-attrib', '.leaflet-control-attribution', '.ol-attribution'],
liveReadout: ['[data-testid="timestamp"]', '.coordinate-readout', '.feature-query-value'],
};
Keep attribution on the list: its text reflows when a tile source rotates a data-vintage string, and the control resizes, shifting its own bounding box by a few pixels.
2. Prefer the runner’s native mask with a stable color
Playwright’s page.screenshot({ mask }) paints an opaque box over each matched locator before the image is encoded, so the volatile content never reaches the comparator. Always pin maskColor to a fixed, fully opaque value — the default is a semi-transparent magenta that composites differently against whatever is beneath it, reintroducing the variance you are removing. A flat dark fill diffs to zero against itself on every run.
await page.screenshot({
path: 'current/map-with-popup.png',
animations: 'disabled',
mask: [
page.locator('.maplibregl-popup'),
page.locator('.maplibregl-ctrl-attrib'),
page.locator('[data-testid="timestamp"]'),
],
maskColor: '#101820', // opaque; identical bytes under the box every run
});
The native mask is the right default because it measures each locator’s live bounding box at capture time — it tracks an element that moved without you recomputing anything.
3. Fall back to injected CSS when the tool lacks masking
Puppeteer, jest-image-snapshot, and most self-hosted comparators have no native mask. There, blank the region with an injected stylesheet instead. Use visibility: hidden with pointer-events: none, never display: none — collapsing the box reflows neighbouring nodes and can nudge the map container itself, manufacturing the drift you set out to remove. Where you need the region positively painted rather than merely hidden (so a stray child cannot leak through), overlay an absolutely-positioned block pinned to the element’s box.
await page.addStyleTag({
content: `
.maplibregl-popup, .mapboxgl-popup, .leaflet-popup,
.maplibregl-ctrl-attrib, .leaflet-control-attribution,
[data-testid="timestamp"] {
visibility: hidden !important;
pointer-events: none !important;
}
`,
});
Inject this only after the map has settled (step 5), or a late relayout re-shows the element after the rule ran.
4. Compute a stable bounding box for coordinate-anchored popups
A GL popup is positioned by projecting a geographic anchor to a screen pixel, so its box origin carries sub-pixel jitter and its size depends on content that may itself vary. When you must mask by rectangle — a canvas-drawn callout, or a comparator that takes coordinates rather than selectors — do not read the live DOM box, which inherits that jitter. Project the anchor yourself, snap to integer pixels, and pad by a couple of device pixels on every side so anti-aliased edges fall inside the mask.
const box = await page.evaluate(() => {
const map = window.map;
const p = map.project([-0.1278, 51.5074]); // WGS84 anchor → screen px
const w = 240, h = 120, pad = 2; // popup nominal size + AA guard
return {
x: Math.floor(p.x - w / 2 - pad),
y: Math.floor(p.y - h - pad), // popup sits above its anchor
width: w + pad * 2,
height: h + pad * 2,
};
});
For a rectangle expressed as a fraction of the frame, the padded mask width relative to viewport width
which lets you keep the same relative exclusion across viewports even when the pixel dimensions change.
5. Gate masking on the map idle anchor
Apply every mask — native locators, injected CSS, or rectangle — only after the map’s terminal idle event. Masking mid-flight either catches a popup still easing into place or lets the library re-inject the node after your rule ran. This is the same synchronization contract enforced across the capture pipeline; a popup that opens on moveend is not present until the camera settles.
await page.evaluate(
() => new Promise((resolve) => window.map.once('idle', resolve))
);
// now inject CSS / compute boxes / take the masked screenshot
6. Verify the mask covers the element across viewports
A mask sized for one viewport can uncover an edge at another: text wraps differently, the popup grows taller, attribution reflows to two lines. Loop the mask-and-capture across your DPR and viewport matrix and assert that no volatile pixel survives. Read the element’s live box after each resize and confirm the mask still contains it with margin to spare.
for (const vp of [{ width: 1024, height: 768 }, { width: 1440, height: 900 }]) {
await page.setViewportSize(vp);
await page.evaluate(() => new Promise((r) => window.map.once('idle', r)));
const el = await page.locator('.maplibregl-popup').boundingBox();
// assert the planned mask rectangle fully contains `el` before capturing
}
Verification
A correct setup blanks only the enumerated selectors, leaves the container box untouched, and still catches a genuine fault a few pixels outside the mask edge.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Popup edge or tail still shows past the mask | Mask sized from a stale box, or the popup grew taller at this DPR | Pad the rectangle per step 4 and re-measure the live box per viewport in step 6 before capturing |
| Whole map shifted a few pixels once the mask applied | An injected rule used display: none, reflowing the container |
Switch to visibility: hidden plus pointer-events: none, or overlay an absolutely-positioned block that does not affect layout |
| Masked region diffs non-zero between identical runs | maskColor left at the default semi-transparent fill, composited over varying pixels |
Pin an opaque maskColor (for example #101820) so the box is byte-identical every run |
Frequently asked questions
Should I mask the popup or just prevent it from opening?
Prevent it when a clean basemap is what you are testing — launch the map with interactive: false and never trigger the popup, which gives a real, fully-compared frame with nothing excluded. Mask by selector only when the popup must stay open for the scenario but its contents (a live timestamp, a queried feature value) carry no cartographic meaning. Every masked region is one the gate no longer inspects, so keep the exclusion as tight as the volatile content.
Why use visibility: hidden instead of display: none on a popup?
display: none removes the box from layout, which reflows sibling nodes and can nudge the map container itself, so two runs differ by a few pixels across the whole frame — the exact drift you are trying to remove. visibility: hidden keeps the element’s geometry intact and simply stops it painting, preserving every surrounding pixel. Add pointer-events: none so a synthetic pointer cannot re-trigger it during capture.
How do I mask a popup whose position depends on map coordinates?
Do not read the live DOM box, which inherits the sub-pixel jitter of projecting a floating-point geographic anchor to a screen pixel. Project the anchor yourself with map.project(), snap the origin to integer pixels, and pad a couple of device pixels on each side so anti-aliased edges fall inside the mask. Recompute the box after any camera change, and gate the whole thing on the map idle event so the anchor has settled first.
Can I rely on Playwright's native mask option alone?
For selector-addressable DOM popups, tooltips, and attribution, yes — the native mask measures each locator’s live box and paints an opaque rectangle before encoding, which is why step 2 makes it the default. It cannot reach a callout drawn straight into the WebGL context; for that, compute a projected rectangle per step 4. On runners without a native mask, the injected-CSS fallback in step 3 does the same job.
Related
- Up to the parent guide Interactive Overlay Masking Rules, and the parent reference Dynamic Element Masking & UI Stability.
- How to mask dynamic user cursors and tooltips in map tests — the pointer-driven sibling to this selector-based recipe.
- Animated Tile Layer Stabilization — freezing live weather and traffic layers whose motion no mask can catch.
- Dynamic Threshold Configuration — stratifying tolerance for the regions that survive masking.