ScreenshotNeo

BlogHTML to image & PDF

Why Puppeteer Ignores CSS @media print Rules and How to Fix It

Puppeteer normally uses print CSS for PDFs. Learn why @media print appears broken, how to verify media state, and how to fix layout, colors, fonts, and timing.

By the ScreenshotNeo team30 September 20267 min read

Why Puppeteer Ignores CSS @media print Rules and How to Fix It

Short answer: Puppeteer does not generally ignore @media print. According to the Page.pdf() documentation, page.pdf() generates a PDF using the print CSS media type by default. If print rules are missing, check whether your code called page.emulateMediaType('screen'), whether the stylesheet and selectors match, and whether the page finished rendering before PDF generation.

This guide gives you a repeatable diagnosis, complete Puppeteer examples, PDF option details, and fixes for the symptoms developers usually describe as “Puppeteer PDF not applying print CSS.”

What media type does Puppeteer use for PDFs?

The default media type for page.pdf() is print. A stylesheet such as this should therefore apply during normal PDF generation:

@media print {
  .screen-only { display: none; }
  .invoice { break-inside: avoid; }
}

Puppeteer also supports explicit media emulation. page.emulateMediaType('screen') selects screen media, while page.emulateMediaType('print') selects print media. Passing null disables CSS media emulation. See the official emulateMediaType reference.

First diagnostic: check for a screen override

Search every code path that runs before page.pdf(). This is the most common configuration explanation when print rules appear inactive:

The reliable capture sequence: load, select media, verify readiness, then generate the PDF.
The reliable capture sequence: load, select media, verify readiness, then generate the PDF.
await page.emulateMediaType('screen');
const pdf = await page.pdf();

Remove the screen override when you want print styling, or replace it with an explicit print setting. The explicit call is useful while debugging even though it is not normally required.

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch();
const page = await browser.newPage();

await page.goto('https://example.com', { waitUntil: 'networkidle2' });
await page.emulateMediaType('print');

const mediaState = await page.evaluate(() => ({
  print: matchMedia('print').matches,
  screen: matchMedia('screen').matches,
}));
console.log(mediaState); // { print: true, screen: false }

await page.pdf({
  path: 'output.pdf',
  format: 'A4',
  printBackground: true,
});

await browser.close();

This check tells you which media query is active. It does not prove that a stylesheet loaded, that a selector matches, or that the declaration wins the cascade.

A minimal reproducible print-CSS example

Start with a tiny page that makes the expected behavior obvious. If this works but your application does not, the problem is page-specific.

import puppeteer from 'puppeteer';

const html = `<!doctype html>
<html>
<head>
  <style>
    body { font-family: sans-serif; }
    .screen-only { background: #ffe08a; padding: 12px; }
    @media print {
      .screen-only { display: none; }
      .print-only { display: block; }
    }
    .print-only { display: none; }
  </style>
</head>
<body>
  <div class="screen-only">Visible on screen</div>
  <div class="print-only">Visible in the PDF</div>
</body>
</html>`;

const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'load' });
await page.emulateMediaType('print');
await page.pdf({ path: 'print-test.pdf', printBackground: true });
await browser.close();

Complete PDF generation with reliable readiness checks

Navigation completion and application readiness are separate concerns. Puppeteer waits for fonts by default during PDF generation, but that does not guarantee that your API data, charts, images, or client-side rendering have completed. Add an application-owned readiness signal.

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://example.com/report', {
  waitUntil: 'networkidle2',
});

await page.waitForSelector('[data-report-ready]', { timeout: 30000 });
await page.emulateMediaType('print');

const state = await page.evaluate(() => ({
  print: matchMedia('print').matches,
  ready: Boolean(document.querySelector('[data-report-ready]')),
}));
console.log(state);

await page.pdf({
  path: 'report.pdf',
  format: 'A4',
  printBackground: true,
  preferCSSPageSize: true,
  margin: { top: '12mm', right: '12mm', bottom: '12mm', left: '12mm' },
});
await browser.close();

networkidle2 is useful for navigation, but it is not a universal guarantee that every application render is complete. Prefer a selector, promise, or explicit window flag controlled by your application.

PDF options that are often mistaken for media problems

Symptom Relevant option What it controls
Background colors or images are missing printBackground Background painting. Its default is false.
CSS page size is ignored preferCSSPageSize When true, CSS @page dimensions take priority over PDF size options.
Paper size is wrong format, width, height PDF geometry. These are independent of the active media type.
Content is too large or too small scale, margins, viewport Scaling and available page area.
Landscape output is expected landscape: true Orientation.
Colors look muted -webkit-print-color-adjust Color adjustment for printing; it does not activate a media query.

For example:

@page {
  size: A4;
  margin: 12mm;
}

html {
  -webkit-print-color-adjust: exact;
}
await page.pdf({
  path: 'styled.pdf',
  printBackground: true,
  preferCSSPageSize: true,
});

Why a print rule can still appear not to work

1. A screen emulation call runs later

Check helper functions, shared PDF utilities, and middleware. A later emulateMediaType('screen') call wins. Log media state immediately before page.pdf().

2. The stylesheet did not load

Inspect response status, stylesheet URLs, CSP errors, blocked requests, and relative paths. In a page with frames, confirm that the stylesheet belongs to the frame containing the printed element.

3. The selector does not match

Use page.$eval() or browser developer tools to verify the class, attribute, and DOM structure. A rule cannot apply to an element that is created later or rendered inside a shadow root you did not target.

4. Another declaration wins

Inline styles, higher-specificity selectors, !important, and later rules can override a print declaration. Inspect computed styles under print media and compare the winning declaration.

5. The content is in a different page or frame

Puppeteer prints the current page. If your app opens a popup, uses an iframe, or navigates to a new document, make sure you apply media emulation and readiness checks to the page that produces the PDF.

6. The page is captured before dynamic content is ready

Charts, images, fonts, and data-driven components may still be changing. Wait for an application signal, then capture. Do not assume that an idle network automatically means the UI is complete.

Media selection and page geometry are separate. Use print rules for visibility and layout, then use @page and PDF options for paper behavior.

@media print {
  .toolbar, .navigation, .chat-widget { display: none !important; }
  .invoice { break-inside: avoid; }
  h2 { break-before: page; }
}

@page {
  size: Letter;
  margin: 0.5in;
}

If CSS dimensions should control the final page, set preferCSSPageSize: true. Otherwise Puppeteer’s format, width, or height can determine the PDF geometry.

Debugging checklist

  1. Confirm the code calls page.pdf() after navigation and app readiness.
  2. Search for every emulateMediaType call.
  3. Log matchMedia('print').matches immediately before capture.
  4. Check stylesheet responses and browser console errors.
  5. Verify the selector against the actual DOM and frame.
  6. Inspect computed styles and cascade order.
  7. Enable printBackground: true for backgrounds.
  8. Review format, dimensions, margins, scale, and preferCSSPageSize.
  9. Wait for a page-specific ready signal.
  10. Compare a minimal reproduction with the real page.

Or skip the browser setup

If your goal is a clean screenshot or PDF of a URL rather than browser-level control, ScreenshotNeo provides a single request API. Its capture service accepts consent banners before taking the shot, then removes more than 60 known consent platforms along with newsletter popups and chat widgets. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing result.

A clean capture removes common consent and overlay elements before rendering the final image.
A clean capture removes common consent and overlay elements before rendering the final image.

See the ScreenshotNeo API documentation for all options. A basic request is:

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

ScreenshotNeo also supports full-page capture, CSS-selector element capture, dark mode, device presets, custom viewports, retina scale, PDF paper settings and page ranges, custom CSS and JavaScript, click and wait actions, request blocking, headers, cookies, user agents, timezone, geolocation, transparent backgrounds, resizing, TTL caching, signed links, asynchronous webhooks, bulk capture, usage reporting, and an MCP server with take_screenshot, get_page_info, and capture_pdf for AI clients.

There is a free plan with 1,000 screenshots per month and no card. Paid plans start at $5 for 3,000 screenshots, and every feature is available on every plan. Create a free ScreenshotNeo account.

Performance, reliability, and cost considerations

Browser PDF generation costs time and memory for each Chromium page. Reuse a browser process when generating many documents, create isolated pages per job, close pages after capture, and avoid waiting on unnecessary third-party requests. Use explicit readiness signals so retries do not produce partially rendered PDFs.

For repeatable output, pin the Puppeteer version, keep CSS page dimensions explicit, and record the URL, media state, PDF options, and application revision with each artifact. When using ScreenshotNeo, choose a cache TTL for stable pages, use asynchronous jobs and signed webhooks for long runs, and use bulk capture for up to 100 URLs per call. Clean shots are billed; failed loads and cache hits are not.

FAQ

Does page.pdf() require emulateMediaType(‘print’)?

No. Print media is the documented default. An explicit call is useful for diagnosis and makes intent clear.

How do I generate a screen-styled PDF?

Call await page.emulateMediaType('screen') before page.pdf().

Why are my print backgrounds missing?

Set printBackground: true. Background painting is separate from media-query activation.

Does networkidle2 guarantee a complete PDF?

No. Wait for the application’s own ready condition when content is rendered asynchronously.

Can I check the active media query?

Yes: await page.evaluate(() => matchMedia('print').matches).

When should I use preferCSSPageSize?

Use it when CSS @page dimensions should take precedence over Puppeteer’s PDF size settings.