· Tutorials · By Simon Chiu

Hide Cookie Banners and Chat Widgets in Puppeteer

Remove cookie banners, consent iframes, shadow-DOM dialogs and chat widgets from Puppeteer screenshots: a tested module and what each method misses.

To hide cookie banners and chat widgets in Puppeteer screenshots, do three things in order: inject CSS that hides known containers before the page's own scripts run, click an accept button in every frame and open shadow root, and remove the scroll lock that many banners leave behind. This module does all three; we tested it against four fixture pages built to fail in different ways.

// clean-page.mjs: hide, dismiss and unlock before a screenshot.
export const HIDE_SELECTORS = [
  // consent managers
  '#onetrust-consent-sdk', '#onetrust-banner-sdk', '#CybotCookiebotDialog',
  '#usercentrics-root', '#didomi-host', '#qc-cmp2-container',
  '[id^="sp_message_container"]', 'iframe[id^="sp_message_iframe"]',
  '.fc-consent-root', '#truste-consent-track',
  // chat widgets
  '#intercom-container', '#intercom-frame', '.intercom-lightweight-app',
  '#hubspot-messages-iframe-container', '#crisp-chatbox', '#launcher',
];

const ACCEPT_TEXT = /^(accept( all)?( cookies)?|allow all|agree|i agree|got it|ok)$/i;

// Layer 1: CSS that exists before the page's own scripts run.
export async function installHideRules(page, selectors = HIDE_SELECTORS) {
  const css = `${selectors.join(',\n')} { display: none !important; }`;
  await page.evaluateOnNewDocument((css) => {
    const add = () => {
      const style = document.createElement('style');
      style.dataset.screenshot = 'hide';
      style.textContent = css;
      document.documentElement.append(style);
    };
    if (document.documentElement) add();
    else document.addEventListener('DOMContentLoaded', add, { once: true });
  }, css);
}

// Layer 2: click a consent button in any frame, light DOM or open shadow DOM.
export async function clickAccept(page) {
  let clicked = 0;
  for (const frame of page.frames()) {
    if (frame.detached) continue;
    try {
      clicked += await frame.evaluate((source) => {
        const re = new RegExp(source, 'i');
        const roots = [document];
        for (const el of document.querySelectorAll('*')) if (el.shadowRoot) roots.push(el.shadowRoot);
        for (const root of roots) {
          for (const b of root.querySelectorAll('button, [role="button"], a')) {
            if (re.test(b.textContent.trim().replace(/\s+/g, ' '))) { b.click(); return 1; }
          }
        }
        return 0;
      }, ACCEPT_TEXT.source);
    } catch {
      // cross-origin frames that navigated away; ignore
    }
  }
  return clicked;
}

// Layer 3: undo the scroll lock many banners leave on <html> and <body>.
export async function unlockScroll(page) {
  await page.evaluate(() => {
    for (const el of [document.documentElement, document.body]) {
      if (getComputedStyle(el).overflow === 'hidden') el.style.setProperty('overflow', 'visible', 'important');
    }
  });
}

// Banners often appear after "load". Poll for a short, bounded window.
export async function clickAcceptWithin(page, ms = 3000, every = 250) {
  const deadline = Date.now() + ms;
  while (Date.now() < deadline) {
    if (await clickAccept(page)) return true;
    await new Promise((r) => setTimeout(r, every));
  }
  return false;
}

And the script that uses it:

// clean-shot.mjs: node clean-shot.mjs <url> <out.png>
import puppeteer from 'puppeteer';
import { installHideRules, clickAcceptWithin, unlockScroll } from './clean-page.mjs';

const [url = 'https://example.com', out = 'clean.png'] = process.argv.slice(2);
const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();
  await page.setViewport({ width: 1280, height: 800 });
  await page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'light' }]);
  await installHideRules(page);                 // before goto
  await page.goto(url, { waitUntil: 'load', timeout: 30_000 });
  const clicked = await clickAcceptWithin(page, 3000);
  await unlockScroll(page);
  await page.screenshot({ path: out });
  console.log(JSON.stringify({ url, clicked }));
} finally {
  await browser.close();
}

This page belongs to our Puppeteer screenshot guide, which covers the base recipes. Here we look only at overlays: what each layer removes, what it misses, and what it costs.

Tested on 2026-10-18 with Node.js 22.18.0 and [email protected] (Chrome 155.0.8059.79) on macOS 27.0.1, Apple M1 Pro. The targets were four local fixture pages we wrote, plus https://example.com as a page with no banner. We did not test against live consent-heavy sites for this page. The selector list comes from the IDs that these vendors' default embeds use; vendors change their markup, so check it against your own target URLs.

The four shapes of overlay

Banner code differs by vendor, but the parts that break screenshots fall into a few shapes. We built one fixture for each:

Fixture What it does Why it is hard
Late DOM banner Adds a fixed #onetrust-banner-sdk 600 ms after load and sets overflow: hidden on <body> Not there at load; the scroll lock outlives the banner
Iframe banner A full-screen dark backdrop div plus the dialog in iframe#sp_message_iframe_1; the Accept button lives inside the iframe Main-document selectors cannot see the button; the backdrop is a separate element
Shadow DOM banner Dialog inside the open shadow root of #usercentrics-root querySelectorAll does not enter shadow roots
Chat widget Adds an iframe#intercom-frame launcher and a .intercom-lightweight-app greeting 800 ms after load No button to click; there is no consent to give

Each fixture is a short HTML file. The late banner, for example:

<!doctype html><meta charset=utf-8>
<main style="height:1400px"><h1>Pricing</h1><p>Content under the banner.</p></main>
<script>
setTimeout(() => {
  document.body.style.overflow = 'hidden';
  const d = document.createElement('div');
  d.id = 'onetrust-banner-sdk';
  d.style.cssText = 'position:fixed;inset:auto 0 0 0;padding:24px;background:#111;color:#fff;z-index:9999';
  d.innerHTML = 'We use cookies. <button id="onetrust-accept-btn-handler">Accept all</button>';
  d.querySelector('button').onclick = () => { d.remove(); document.body.style.overflow = ''; };
  document.body.append(d);
}, 600);
</script>

What each layer removed

For each fixture and strategy, a fresh page loaded the fixture, waited one second so late overlays could appear, ran the strategy, and then listed every visible fixed-position element, including those inside open shadow roots. "Locked" means <body> still had overflow: hidden.

Fixture Nothing CSS only Click only CSS + click + unlock
Late DOM banner Banner visible, locked Banner hidden, still locked Clean Clean
Iframe banner Backdrop and dialog visible Dialog hidden, backdrop visible Clean Clean
Shadow DOM banner Dialog visible Clean Clean Clean
Chat widget Launcher and greeting visible Clean Both still visible (nothing to click) Clean

Only the combined strategy cleaned all four. Two failures are worth a closer look.

CSS alone left the page locked. The late banner set overflow: hidden on <body>, which is normal for modal consent dialogs. Hiding the banner did not undo it. The lock did not change the screenshot itself: a fullPage capture was 1,480 px tall with and without it, because Puppeteer measures the document. It broke scrolling. With the lock on, page.mouse.wheel({ deltaY: 500 }) left scrollY at 0; after unlockScroll() the same call moved it to 500. Any scroll-to-load step for lazy images or infinite feeds silently does nothing on a locked page.

CSS alone left the backdrop. The iframe fixture draws its dim layer as a sibling div with no vendor ID. Our list hid the iframe, and the page stayed grey. Clicking Accept made the page's own code remove both. That is the main argument for clicking: the page's code knows what it added, and your selector list does not.

Layer 1: hide with CSS before the page runs

page.evaluateOnNewDocument() runs the function in every new document of the page, before any of the page's scripts. The style element is in place before the banner exists, so a banner that arrives late is hidden at once. The function also runs in child frames, so it hides chat launchers that a widget draws inside its own iframe.

Why display: none and not visibility: hidden: visibility keeps the element's space, which leaves a white band where a banner pushed content down. The trade-off is that display: none can shift layout. If a site reserves space for its banner, compare both.

Why not page.addStyleTag() after goto: it runs after load, so a banner shown at DOMContentLoaded is in any screenshot you take in between, and a style added to the top document does not reach the widget's iframe.

Layer 2: click Accept in every frame and shadow root

Three details make the click work on the fixtures:

  1. Every frame. page.frames() lists the main frame and all child frames, same-origin or not. frame.evaluate() runs in each. A cross-origin frame that navigates while you search throws; the catch skips it.
  2. Open shadow roots. The search collects el.shadowRoot for every element and searches those too. Closed shadow roots return null and cannot be searched from page code.
  3. Text, not IDs. The regular expression matches the whole button label: "Accept", "Accept all", "Accept all cookies", "Allow all", "Agree", "I agree", "Got it", "OK". Whole-label matching avoids clicking "Accept and subscribe" or a "Learn more about how we accept payments" link. Add labels for the languages you capture, for example alle akzeptieren or tout accepter.

b.click() is the DOM click(), not a mouse event at screen coordinates. It works even when Layer 1 has already hidden the banner, which is what we want: the page's handler removes the backdrop and the scroll lock.

Timing. Banners often appear after load. On the late-banner fixture, a single clickAccept() right after goto found nothing (0 clicks, 19 ms). clickAcceptWithin() polled every 250 ms and clicked after 802 ms. The cost is on pages with no banner: the poll waits the full window. On example.com it returned false after 3 s. Pick the window from your own pages; one to three seconds is a common range.

Accept or reject? Clicking Accept tells the site that a visitor consented. In a throwaway headless profile that consent goes nowhere, but the page may now load more analytics and ad scripts, which can slow the capture and change what it shows. If you want the reject path, change ACCEPT_TEXT to match "Reject all" or "Only necessary", and test it; reject buttons are often hidden behind a "Settings" step.

Layer 3: unlock scrolling

unlockScroll() checks the computed overflow of <html> and <body> and overrides it only if it is hidden. Overriding it on every page would break sites that use overflow: hidden on purpose, for example full-screen slide decks.

Chat widgets: hide, and optionally block

Chat widgets have no consent step, so only Layer 1 applies. In the chat fixture, the rule list hid the launcher iframe and the greeting card even though both were added 800 ms after load.

You can also stop the widget from loading. That saves the network requests and the CPU time of the widget's script. With request interception, abort requests to the widget's script host:

const WIDGET_HOSTS = ['widget.intercom.io', 'js.intercomcdn.com', 'client.crisp.chat'];

await page.setRequestInterception(true);
page.on('request', (req) => {
  if (req.isInterceptResolutionHandled()) return;
  const { hostname } = new URL(req.url());
  if (WIDGET_HOSTS.includes(hostname)) return req.abort();
  return req.continue();
});

Check the host names in your browser's network panel on a real target; vendors move their scripts. Keep the CSS layer even when you block: a site can self-host the loader.

A generic fallback, and why it is off by default

When the selector list and the click both miss, a heuristic can hide any fixed element that covers a large part of the viewport near the bottom or the center. We do not include it in the module. On real pages it also removes sticky headers, sticky "Add to cart" bars and legitimate modals, and the result looks clean but is wrong. If you need it, log what it removed for every URL and review the log.

Troubleshooting

Symptom Cause Fix
Banner is hidden, but scroll-to-load does nothing Scroll lock on <body> blocks wheel scrolling unlockScroll() before you scroll
Page is dimmed with no banner The backdrop is a separate element Click Accept so the page removes it, or add the backdrop's selector
Banner appears in some screenshots and not others It loads after load, at different times Inject CSS with evaluateOnNewDocument; poll for the button
clickAccept() returns 0 Button text differs, or the banner has not appeared yet Extend the regex; use clickAcceptWithin()
Button is in a shadow root and still not found Closed shadow root Hide the host element with CSS instead
Execution context was destroyed during the click The frame navigated while searching The try/catch skips it; poll again
Every capture takes three seconds longer The poll waits the full window on pages with no banner Shorten the window, or skip the poll for domains you know are clean
Chat launcher still visible It is in its own iframe and loaded before your style Use evaluateOnNewDocument, not addStyleTag
Sticky header disappeared A broad selector or a generic heuristic Use vendor IDs; remove heuristics

The same result with one API option

Each of these layers is code you maintain against vendors who change their markup. RenderScreenshot has the same idea built in: block.cookie_banners clicks consent buttons and then hides known containers, and block.chat_widgets blocks Intercom, Drift, Zendesk, Crisp and others. You can add your own selectors to hide or a button to click for sites the defaults miss. With the Node.js SDK:

import { writeFile } from 'node:fs/promises';
import { Client, TakeOptions } from 'renderscreenshot';

const client = new Client(process.env.RS_API_KEY);
const image = await client.take(
  TakeOptions.url('https://example.com')
    .width(1280)
    .height(800)
    .blockCookieBanners()
    .blockChatWidgets()
    .hide(['#cmp-overlay'])
    .click('#accept-cookies'),
);
await writeFile('clean.png', image);

As a GET request, block_cookies=true maps to block.cookie_banners, and hide and click take selectors. The blocking options and page modifications pages list every field, and the ads and cookie banners guide has examples. Our engineering post on how automatic cookie-banner blocking fails shows the fixtures our own handler still misses. Sign up for 150 free credits a month and compare it with your Puppeteer output on your own URLs.

Related in this topic