Features Pricing Docs Blog Try Demo Log In Sign Up
Back to Blog

How I hide cookie banners, ads and chat widgets in screenshots

Last month a cookie banner ate 40% of my screenshot. Here's the three-layer approach I built to block cookie dialogs, chat widgets and ads before capturing the image — with the actual Playwright code from screenshotrun's renderer.

How I hide cookie banners, ads and chat widgets in screenshots

Last month I was taking a screenshot of a client's landing page for their Open Graph card, and the result was embarrassing. Roughly 40% of the image was a two-column cookie consent dialog from OneTrust. A giant Intercom bubble was eating the bottom-right corner. And somewhere between the hero section and the fold, Google Ads had helpfully inserted a banner about car insurance.

That screenshot was useless. I needed a clean shot of the actual page.

It's the most common reason I see screenshots come out looking broken, and I've been chasing this problem inside screenshotrun for a while now. The solution I ended up with isn't a one-liner. It's three layers of blocking that work together, and below is the walkthrough with the actual code I use inside the renderer.

What you actually need to block (more than you think)

When I first started dealing with this, I assumed "just block the cookie banner and I'm done." I was wrong. Modern websites are a mess of overlays, and screenshots expose every single one of them.

Here's what typically ruins a screenshot in my experience:

  • Cookie consent dialogs from vendors like OneTrust, Cookiebot, Osano, Iubenda, Klaro, and a dozen smaller ones I'd never heard of until they broke an image

  • Chat widgets: Intercom is the loudest, but Crisp, Tawk.to, Drift, Zendesk, HubSpot, Tidio and Chatwoot show up too

  • Ad network injections that drop banners, iframes, or entire overlay blocks right into the page

  • A handful of analytics and tracking scripts that occasionally render visible elements (rare, but it happens)

  • The hardest category of all: newsletter popups and exit-intent overlays, which change every week

Some of these arrive via third-party scripts and appear 2-3 seconds after page load. Others are baked into the server-rendered HTML from the start. A few use Shadow DOM -- a browser mechanism that isolates a component's markup from the rest of the page, so regular CSS selectors can't reach inside -- specifically to resist removal. Each type needs its own strategy, which is why I ended up layering.

The three-layer approach I settled on

I tried single-layer fixes first. None of them worked reliably. Here's what finally stuck.

Layer 1 blocks the network requests before the overlay scripts even load. Layer 2 hides the DOM elements that slip through via CSS injection. Layer 3 clicks "Accept" on what can't be blocked and then removes the elements from the tree. Each layer catches what the previous one missed.

Let me show you each piece.

Layer 1: intercept and abort requests at the network level

The cheapest fix is to never let the overlay scripts (those are the cookie banners, ad blocks and chat bubbles that pop up over the page) load in the first place. Playwright makes this easy through context.route, and I pair it with a list of regex patterns for the usual suspects:

const AD_PATTERNS = [
    /doubleclick\.net/,
    /googlesyndication\.com/,
    /googleadservices\.com/,
    /google-analytics\.com/,
    /googletagmanager\.com/,
    /facebook\.net.*\/signals/,
    /hotjar\.com/,
    /mixpanel\.com/,
    // ...and about ten more
];

const COOKIE_CONSENT_PATTERNS = [
    /onetrust\.com/,
    /cookielaw\.org/,
    /cookiebot\.com/,
    /osano\.com/,
    /iubenda\.com.*\/cookie-solution/,
    /klaro\.kiprotect\.com/,
    // ...
];

await context.route('**/*', (route) => {
    const requestUrl = route.request().url();

    if (AD_PATTERNS.some(p => p.test(requestUrl))) {
        return route.abort();
    }
    if (COOKIE_CONSENT_PATTERNS.some(p => p.test(requestUrl))) {
        return route.abort();
    }

    return route.continue();
});

Two things are worth noticing here. First, I use regex instead of exact domain matching, because these vendors constantly rotate subdomains and CDN paths. Second, the route has to be set up before calling page.goto(). If you attach the handler after navigation starts, the first wave of requests slips through and the banner initializes before your code ever runs.

This layer alone kills about 60% of the junk on a typical page. The scripts never arrive, so their overlays never render.

But here's the thing: some sites self-host their cookie banners, or they bake them directly into the server-rendered HTML. Network blocking does nothing for those. That's where layer 2 comes in.

Layer 2: inject CSS to hide the leftover overlays

For banners that survive the network layer, I inject a stylesheet that targets every known container by ID, class, and attribute selector. The list I ended up with covers the major vendors plus some generic fallbacks:

const COOKIE_BANNER_SELECTORS = [
    '#onetrust-banner-sdk',
    '#onetrust-consent-sdk',
    '#CybotCookiebotDialog',
    '.cc-window',
    '.cc-banner',
    '#cookie-banner',
    '#cookie-consent',
    '.cookie-notice',
    '#gdpr-consent-tool',
    '.osano-cm-window',
    '#iubenda-cs-banner',
    '[class*="cookie-banner"]',
    '[id*="cookie-consent"]',
    '[data-testid="cookie-banner"]',
    // ...
];

const cookieCSS = COOKIE_BANNER_SELECTORS.map(s =>
    `${s} { display: none !important; visibility: hidden !important; opacity: 0 !important; pointer-events: none !important; height: 0 !important; overflow: hidden !important; }`
).join('\n');

await page.addStyleTag({ content: cookieCSS });

Notice the [class*="cookie-banner"] attribute selector near the bottom. That's my safety net for sites that roll their own banner. If a developer named their class site-cookie-banner or main-cookie-consent, the attribute selector catches it. Not perfect, but it handles most of what I see in the wild.

I also set every visibility-related property I can think of (display, visibility, opacity, pointer-events, height, overflow). Some banners use JavaScript to override a single property at runtime, so I belt-and-suspender the whole thing and let CSS specificity sort it out.

Layer 3: click accept, then delete from the DOM

CSS hiding has a subtle problem. The banner is still in the DOM, and some sites refuse to scroll properly while it's there because they lock body { overflow: hidden }. For those cases I fall back to clicking the accept button and then removing the element entirely:

const COOKIE_ACCEPT_SELECTORS = [
    '#onetrust-accept-btn-handler',
    '#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll',
    '.cc-btn.cc-allow',
    '.osano-cm-accept-all',
    '#iubenda-cs-accept-btn',
    'button[class*="cookie-accept"]',
    'button[id*="accept-cookie"]',
    // ...
];

for (const acceptSelector of COOKIE_ACCEPT_SELECTORS) {
    try {
        const btn = await page.$(acceptSelector);
        if (btn && await btn.isVisible()) {
            await btn.click();
            await page.waitForTimeout(300);
            break;
        }
    } catch (e) {
        // Ignore click errors. CSS is still the fallback.
    }
}

// Then nuke any remaining banner elements
await page.evaluate((selectors) => {
    for (const s of selectors) {
        document.querySelectorAll(s).forEach(el => el.remove());
    }
}, COOKIE_BANNER_SELECTORS);

I try the accept buttons in order of most common vendor. As soon as one clicks successfully I break out of the loop. The 300ms wait gives the banner's dismiss animation time to finish -- I learned the hard way that clicking and immediately screenshotting captures the banner mid-fade.

And if nothing works? The querySelectorAll().remove() at the end just deletes every matching element. It's aggressive, but the screenshot was going to be broken either way.

Chat widgets use the same pattern with a different list

Chat widgets are structurally identical to cookie banners: third-party script, positioned container, often an injected iframe. I use the same three-layer approach, just with different patterns:

const CHAT_WIDGET_PATTERNS = [
    /widget\.intercom\.io/,
    /client\.crisp\.chat/,
    /embed\.tawk\.to/,
    /widget\.drift\.com/,
    /static\.zdassets\.com/,
    /js\.hs-scripts\.com/,
    /code\.tidio\.co/,
];

const CHAT_WIDGET_SELECTORS = [
    '#intercom-container',
    '.crisp-client',
    '#tawk-bubble-container',
    '#drift-widget-container',
    '.zEWidget-launcher',
    '#hubspot-messages-iframe-container',
    '#tidio-chat',
    'iframe[src*="tidio"]',
];

One thing tripped me up here for longer than it should have: Intercom and several others render their bubble inside an iframe. You can't hide elements inside a cross-origin iframe with CSS injection, no matter what you do. The only thing that works is hiding the iframe's container. That's why the selector list targets #intercom-container rather than trying to reach the bubble itself.

The MutationObserver trick for lazy-loaded widgets

Some widgets get injected 3-5 seconds after page load, long after my initial CSS tag is already in place. The DOM didn't have them when I hid things, so they appear anyway -- right as I'm about to take the screenshot.

The fix is a MutationObserver that re-runs the removal logic whenever new nodes get added:

await page.evaluate((selectors) => {
    const observer = new MutationObserver(() => {
        for (const s of selectors) {
            document.querySelectorAll(s).forEach(el => el.remove());
        }
    });
    observer.observe(document.body, { childList: true, subtree: true });
}, hide_selectors);

This runs inside the page and keeps removing matched elements for the rest of the page's life. I pair it with a short waitForTimeout(1000) after setup, so lazy-loaded widgets have a chance to appear and get caught before I capture the image.

If you want the full recipe for waiting on a page to actually finish rendering (fonts, lazy images, animations, network idle), I wrote a separate post about fixing blank images in full-page screenshots that pairs well with this one.

What still doesn't work (and probably won't any time soon)

I want to be honest about where this approach falls apart, because I don't have a clean fix for most of it yet.

Shadow DOM is the worst offender. If a site uses a closed shadow root (mode: 'closed', where JavaScript from outside can't see the component's content at all -- not through selectors, not through element.shadowRoot) to render its banner, my selectors simply can't reach inside. OneTrust did this for a while on some configurations, and my only workaround was to block the script at the network layer, which obviously only works if I know the vendor's domain ahead of time.

Custom-built banners with randomized class names (looking at you, Next.js sites that ship a fresh build hash every day) are hit-or-miss. The [class*="cookie"] attribute selector catches many of them, but not all.

A/B-tested banners are a nightmare. You can't maintain selectors for something that changes every week.

And finally, some banners load from the same origin as the main site, so network blocking is useless. I can't abort a request to /static/js/consent.js without risking breaking half the app.

If you've solved any of these cleanly, I'd genuinely like to hear about it.

How I bundled this into a single API flag

Inside screenshotrun all of this lives behind three boolean flags: block_cookies, block_chats and block_ads. You send a request like this:

curl -X POST https://api.screenshotrun.com/v1/screenshots \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -d '{
    "url": "https://example.com",
    "block_cookies": true,
    "block_chats": true,
    "block_ads": true
  }'

The renderer runs every layer I described above before capturing the image. If you've been hand-rolling this with Playwright and don't want to maintain the selector lists yourself, that's the fastest way to skip the work. If you want to see how I think about building this kind of thing yourself versus paying for an API, I wrote about that tradeoff in my post on screenshot APIs vs. Puppeteer/Playwright.

A few more things worth reading

Once you have clean screenshots, there's a whole second set of problems around when and where to take them. A few related posts from the blog:

I hope the code in this post saves you the debugging time I spent figuring it out. If you run into a banner pattern I missed, send it my way and I'll add it to the lists.

More from the blog

View all posts
How to take a website screenshot with Java (Selenium, Playwright, API)

How to take a website screenshot with Java (Selenium, Playwright, API)

I kept putting off the Java tutorial because I expected mountains of boilerplate. Turns out Java's built-in HttpClient makes API calls almost as clean as Python's requests. Here are three ways to capture website screenshots with Java — Selenium, Playwright, and an API — with working code, honest comparison, and a Spring Boot integration example.

Read more →
How to take screenshots of password-protected pages with a screenshot API

How to take screenshots of password-protected pages with a screenshot API

I kept running into the same problem — send a dashboard URL to a screenshot API, get back a picture of the login form. Turns out, passing a session cookie along with the request is all it takes. Here's how I set it up for my own admin panel, with code for cURL, Node.js, and PHP, plus two other auth methods for staging servers and token-protected pages.

Read more →
Screenshot API vs Puppeteer/Playwright: when to build and when to buy

Screenshot API vs Puppeteer/Playwright: when to build and when to buy

I built both sides of this equation — a Playwright renderer and a screenshot API on top of it. Most "build vs buy" articles are written by API vendors pushing you to buy. This one walks through the real problems (lazy loading, cookie banners, memory, timeouts), gives you honest thresholds for when each approach wins, and lets you decide.

Read more →