ScreenshotNeo

BlogHow-to

How to Inject CSS from a String Before Capturing a Webpage

Inject CSS before a screenshot with Playwright or Puppeteer, handle timing, frames and Shadow DOM, and remove banners reliably.

By the ScreenshotNeo team1 October 20268 min read

To inject CSS from a string before capturing a webpage, wait for the page to render, add the stylesheet, wait for fonts and layout to settle, then take the screenshot. In Playwright, use page.screenshot({ style }) for a capture-only override or page.addStyleTag({ content }) when the style should remain active in the document.

import { chromium } from 'playwright';

const cssString = `
  .cookie-banner, .chat-widget { display: none !important; }
  * { animation: none !important; transition: none !important; }
`;

const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });

await page.goto('https://example.com', { waitUntil: 'networkidle' });
await page.addStyleTag({ content: cssString });
await page.evaluate(() => document.fonts.ready);
await page.evaluate(() => new Promise(requestAnimationFrame));
await page.screenshot({ path: 'capture.png', fullPage: true });

await browser.close();

Playwright documents addStyleTag as adding a stylesheet link or a style element containing supplied content, and the call resolves after the CSS is injected into the frame. See the Playwright addStyleTag documentation.

Use capture-scoped CSS with Playwright

If the rules are needed only for one screenshot, pass the string through the screenshot style option:

const cssString = `
  .cookie-banner, .newsletter-modal, .chat-widget { display: none !important; }
  video, canvas { visibility: hidden !important; }
  *, *::before, *::after {
    animation: none !important;
    transition: none !important;
    caret-color: transparent !important;
  }
`;

await page.goto('https://example.com', { waitUntil: 'networkidle' });
await page.evaluate(() => document.fonts.ready);
await page.screenshot({
  path: 'capture.png',
  fullPage: true,
  style: cssString
});

The style parameter is the clearest choice for a one-off capture. Playwright documents it as the stylesheet text applied while making the screenshot, including coverage of Shadow DOM and inner frames. See the screenshot parameter documentation.

Keep the stylesheet active with addStyleTag

Use a persistent style when you need to inspect the altered page, measure it, take several screenshots, or run additional page code against the modified layout.

const cssString = `
  .pricing-banner { display: none !important; }
  .hero { max-width: 960px !important; }
`;

await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.hero');
const styleHandle = await page.addStyleTag({ content: cssString });
await page.evaluate(() => document.fonts.ready);
await page.evaluate(() => new Promise(requestAnimationFrame));
await page.screenshot({ path: 'capture.png', fullPage: true });

// Remove the override before the next capture, if required.
await styleHandle.evaluate((style) => style.remove());

Waiting for a selector before injection matters on client-rendered sites. A stylesheet cannot hide or restyle a component that has not been created yet.

Complete Playwright example with reusable CSS

import { chromium } from 'playwright';

const targetUrl = process.argv[2] || 'https://example.com';
const cssString = `
  .cookie-banner,
  [aria-label*="cookie" i],
  .chat-widget,
  .intercom-lightweight-app { display: none !important; }
  html { scroll-behavior: auto !important; }
  *, *::before, *::after {
    animation-delay: 0s !important;
    animation-duration: 0s !important;
    transition-duration: 0s !important;
  }
`;

const browser = await chromium.launch();
try {
  const page = await browser.newPage({
    viewport: { width: 1440, height: 900 },
    deviceScaleFactor: 1
  });
  await page.goto(targetUrl, { waitUntil: 'networkidle', timeout: 60000 });
  await page.waitForLoadState('domcontentloaded');
  await page.addStyleTag({ content: cssString });
  await page.evaluate(() => document.fonts.ready);
  await page.evaluate(() => new Promise(requestAnimationFrame));
  await page.screenshot({ path: 'capture.png', fullPage: true });
} finally {
  await browser.close();
}

Puppeteer equivalent

Puppeteer supports the same persistent approach:

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch();
const page = await browser.newPage();
const cssString = `
  .cookie-banner, .chat-widget { display: none !important; }
  * { animation: none !important; transition: none !important; }
`;

await page.goto('https://example.com', { waitUntil: 'networkidle0' });
await page.addStyleTag({ content: cssString });
await page.evaluate(() => document.fonts.ready);
await page.screenshot({ path: 'capture.png', fullPage: true });
await browser.close();

For custom logic, insert a tagged style element with page.evaluate. Puppeteer’s page.evaluate documentation describes running a function in the page context and waiting for its returned promise.

await page.evaluate((css) => {
  const style = document.createElement('style');
  style.dataset.captureOverride = 'true';
  style.textContent = css;
  (document.head || document.documentElement).appendChild(style);
}, cssString);

Hide an element only in a screenshot

Prefer a specific selector and display: none when removing the element should also remove its layout space. Use visibility: hidden when the surrounding geometry must remain unchanged.

const cssString = `
  #accept-cookies,
  .newsletter-modal,
  aside[data-ad-slot] {
    display: none !important;
  }
  .sticky-header {
    position: static !important;
  }
`;

Use !important only when the site’s rules win the cascade. A highly specific selector can be safer than applying !important everywhere:

const cssString = `
  body main section[data-testid="promo"] .close-button {
    opacity: 0 !important;
    pointer-events: none !important;
  }
`;

Timing: inject after rendering, capture after layout

  1. Navigate and choose a wait condition: domcontentloaded for mostly static pages, networkidle when the application settles after requests, or an application-specific selector or readiness flag.
  2. Inject CSS after the target nodes exist.
  3. Wait for document.fonts.ready; otherwise fallback fonts can change line wrapping after the screenshot.
  4. Wait for important images or application promises when content is lazy-loaded.
  5. Give the browser a rendering turn after rules that change layout:
await page.evaluate(() => new Promise(requestAnimationFrame));

CSS injection does not itself wait for fonts, images, hydration, or late API responses. Add those waits explicitly.

Full page, viewport, and element screenshots

fullPage: true captures the document’s full height. Omit it for the current viewport. To capture one component, locate it and call its screenshot method:

const card = page.locator('.invoice-card');
await card.screenshot({ path: 'invoice-card.png' });

Inject rules before either capture type. If your CSS changes height or overflow, verify that the final document dimensions are the ones you intend.

Why injected CSS does not affect an iframe

A top-level document stylesheet does not automatically rewrite a separately loaded iframe. For a same-context frame, inject into the frame itself:

const frame = page.frame({ name: 'report' });
if (!frame) throw new Error('report frame not found');
await frame.addStyleTag({ content: cssString });

For an iframe without a stable name, locate it by URL or element and obtain its frame. Cross-origin isolation can prevent DOM access; in that case, the parent page cannot inject CSS into the child document. The screenshot-scoped Playwright style option is documented to pierce inner frames where the browser context permits it.

Shadow DOM considerations

Selectors in a normal document stylesheet do not cross every shadow boundary. A component with an open shadow root can be modified in its own context:

await page.evaluate((css) => {
  const host = document.querySelector('privacy-banner');
  if (!host?.shadowRoot) return;
  const style = document.createElement('style');
  style.textContent = css;
  host.shadowRoot.appendChild(style);
}, '.banner { display: none !important; }');

For a one-off Playwright screenshot, the screenshot style option is usually simpler because its documented behavior includes Shadow DOM coverage.

Debugging injected styles

Tag persistent styles so you can inspect and remove them:

await page.evaluate((css) => {
  document.querySelector('[data-capture-override]')?.remove();
  const style = document.createElement('style');
  style.dataset.captureOverride = 'true';
  style.textContent = css;
  document.head.appendChild(style);
}, cssString);

To verify that a rule matches, inspect computed styles:

const display = await page.locator('.cookie-banner').evaluate((el) =>
  getComputedStyle(el).display
);
console.log({ display });

Common errors and fixes

Symptom Cause Fix
Element is still visible Selector does not match, the element is inside a frame or Shadow DOM, or a later rule wins. Check the selector in DevTools, inject in the correct frame, increase specificity, or use !important.
CSS works locally but not in CI Different viewport, user agent, consent state, or page timing. Set viewport and context options explicitly and wait for an application-ready selector.
Screenshot has fallback fonts Capture happened before web fonts loaded. Await document.fonts.ready and any font-specific readiness signal.
Layout shifts after injection CSS changed dimensions and capture ran before a paint. Await a requestAnimationFrame, then verify bounding boxes.
Full-page screenshot is unexpectedly tall Hidden content still contributes layout space, or a fixed element is duplicated. Use display: none for removable nodes and inspect document height and overflow.
Cross-origin frame cannot be changed Browser same-origin policy blocks DOM access. Capture the frame separately if possible, or use a capture service that supports the required page context.
Style leaks into later screenshots A persistent style element remains in the page. Keep the returned handle and remove it, or create a new page per capture.

Performance and reliability

  • Reuse a browser process for many pages, but create a fresh page or remove the override between captures.
  • Keep the CSS short and selectors specific; broad expensive selectors can increase style recalculation.
  • Disable animations and transitions for repeatable pixels, but do not hide content that the screenshot is meant to document.
  • Use network-idle waits only when the site eventually becomes idle. Analytics or long polling can prevent that state; an app-specific selector is often more reliable.
  • Set navigation and screenshot timeouts and record the URL, selector, CSS hash, viewport, and browser version with each artifact.
  • For lazy images, scroll or use the framework’s full-page behavior, then wait for important image elements before capture.

Or skip the browser setup

ScreenshotNeo provides a website screenshot API when you do not want to maintain Playwright or Puppeteer. Its request can apply custom CSS and JavaScript, wait for a selector, delay or network idle, hide selectors, click an element, select a viewport or device preset, use dark mode, capture one element, and return PNG, JPEG, WebP or PDF. See the ScreenshotNeo API documentation.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://example.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

Before capture, ScreenshotNeo accepts cookie and consent banners and removes more than 60 known consent platforms, newsletter popups and chat widgets; each step can be turned off. Bot checks, blank pages, timeouts, failed loads and cache hits are not billed, and the response identifies the result with X-Page-Verdict and X-Billed headers. An MCP server provides take_screenshot, get_page_info and capture_pdf tools for Claude, Cursor and other MCP clients. The free plan includes 1,000 screenshots per month without a card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.

FAQ

Should I use style or addStyleTag?

Use style for one capture. Use addStyleTag when the override must remain available for inspection, measurement, or several captures.

Can I pass arbitrary user CSS?

Yes, but treat it as code: validate or restrict untrusted input, avoid unexpected URL imports, and keep selectors scoped to the capture.

Why does hiding an element change the page height?

display: none removes layout space. Use visibility: hidden when you need to preserve geometry.

How do I restore the original page?

Remove the style element returned by addStyleTag, remove your tagged element, or close the page and create a new one.