All WordPress HTML Templates Forms & Webhooks AI & Tools
HTML Templates

Print Stylesheets and PDF Output: The Forgotten Requirement

A practitioner’s guide to print css: the failures that break print output, a baseline print stylesheet, pagination control and server-side PDF rendering.

A real working moment illustrating the theme of an article about print css. Wide 16:9 banner, one strong focal point, magazine editorial quality, authentic and unstaged.
Key Takeaways

  • The three most common print failures are scroll-triggered reveal animations stuck at opacity: 0, loading="lazy" images that never fetch, and overflow: hidden wrappers clipping everything after page one. Fix those three and most sites go from unusable to acceptable.
  • Chrome’s print dialog disables background graphics by default. If your invoice relies on coloured table stripes or a dark header band, you need print-color-adjust: exact and, for server-side PDFs, printBackground: true.
  • Running headers and footers via CSS @page margin boxes (@top-center, counter(page)) do not work in any browser. You get them from WeasyPrint, Prince, Paged.js, or Puppeteer’s non-standard headerTemplate.
  • Block layout fragments reliably across pages. Flex and grid containers still fragment unpredictably in 2026, so for paginated documents, restructure to block or table layout inside the print stylesheet rather than fighting it.
  • DevTools “Emulate CSS media type: print” does not paginate. It only shows you the cascade. Real verification means printing to PDF, ideally in CI with a page-count assertion.

What a print stylesheet actually has to solve

Print isn’t a narrow viewport. It’s a fundamentally different layout model: fixed-size, non-scrolling, paginated, with no interaction, no hover, no JavaScript event loop you can rely on, and a physical cost per page.

Four things change at once:

  • Pagination. Your content gets chopped into fragments. Anything that assumes a continuous scroll container breaks.
  • Fixed canvas. A4 is 210 by 297mm. US Letter is 8.5 by 11 inches, wider and shorter. Your design has to survive both unless you lock the page size.
  • No interactivity. Accordions, tabs, modals, “load more” and tooltips all hide content that the reader now cannot reach.
  • Ink and legibility. Light grey on white at 14px looks refined on a Retina display and vanishes on a 300dpi laser print on recycled stock.

A print stylesheet is a re-layout, not a tweak. Teams that treat it as “hide the nav, set the body to black” ship something that technically prints and is still unreadable.

A real working moment that fits an article section about

The failures we see most on client sites

In rough order of how often they bite:

Scroll-triggered reveals

AOS, GSAP ScrollTrigger, WOW.js, and every hand-rolled IntersectionObserver variant set an initial hidden state in CSS and remove it on intersection. Print never intersects. The nuclear option belongs in every print stylesheet:

@media print {
  / Defeat any reveal animation left in a hidden initial state /
  *,
  *::before,
  *::after {
    animation: none !important;
    transition: none !important;
    opacity: 1 !important;
    transform: none !important;
    visibility: visible !important;
  }
}

Yes, it’s a wall of !important. Print is the one place where that’s defensible, because you’re overriding inline styles written by animation libraries at runtime.

Lazy images

loading="lazy" images below the fold are not fetched when the print renderer lays out the page. You get alt text or a collapsed box. Browsers have improved here (Chrome will force-load lazy images for print in many cases) but behaviour is still inconsistent across engines and completely unreliable for JS-driven lazy loaders using data-src. Force it:

// Resolve lazy images before the print dialog renders.
// beforeprint is synchronous-ish: the browser won't wait for network,
// so also call this on page idle for pages that are known to be printed.
function eagerLoadImages() {
  document.querySelectorAll('img[loading="lazy"]').forEach((img) => {
    img.loading = 'eager';
  });
  document.querySelectorAll('img[data-src]').forEach((img) => {
    img.src = img.dataset.src;
  });
}

window.addEventListener('beforeprint', eagerLoadImages);

// For invoice/report routes, don't gamble on beforeprint timing at all:
if (document.body.dataset.printable === 'true') {
  requestIdleCallback
    ? requestIdleCallback(eagerLoadImages)
    : setTimeout(eagerLoagImages, 1500);
}

Correct that last identifier when you paste it, obviously. For routes you know will be printed, load images eagerly and skip the event entirely.

Clipping wrappers

overflow: hidden on a layout wrapper is the sneakiest print bug. The content exists, it just gets clipped at the first page boundary, and you’ll swear the DOM is broken. Same problem with height: 100vh heroes (one full page of nothing), position: sticky headers, and horizontally scrollable table wrappers that print a cropped third of the table.

@media print {
  .wrapper, .section, .container, .table-responsive {
    overflow: visible !important;
    height: auto !important;
    max-height: none !important;
    position: static !important;
  }
  .hero { min-height: 0 !important; }
}

Everything else that shouldn’t be in the PDF

Cookie consent, chat widget, sticky CTA bar, back-to-top button, skip links, social share rows, “related posts”, comment forms. If you’re on Bootstrap, the d-print-none utility covers one-off cases cleanly, which is one of the quiet reasons Bootstrap 5 still earns its place in production work. Otherwise, define a .no-print class and enforce it in review.

A baseline worth copying

This is close to what we ship as the starting point on client projects. It’s deliberately conservative.

@page {
  size: A4;              / or: size: Letter; or omit to respect the dialog /
  margin: 18mm 16mm 20mm;
}

/ First page gets a tighter top margin because the letterhead sits there /
@page :first {
  margin-top: 12mm;
}

@media print {
  html {
    font-size: 11pt;      / pt is the honest unit here; 96px = 1in /
    background: #fff;
  }

  body {
    color: #000;
    line-height: 1.45;
    font-family: Georgia, "Times New Roman", serif;
    -webkit-print-color-adjust: exact;
    print-color-adjust: exact;   / keep brand colours and table stripes /
  }

  / Give headings somewhere to breathe and never orphan them /
  h2, h3, h4 {
    break-after: avoid;
    page-break-after: avoid;     / older Safari /
    orphans: 3;
    widows: 3;
  }

  p, li, blockquote {
    orphans: 3;
    widows: 3;
  }

  / Repeat table headers on every page /
  thead { display: table-header-group; }
  tfoot { display: table-footer-group; }
  tr, img, figure, pre, blockquote {
    break-inside: avoid;
    page-break-inside: avoid;
  }

  / Expose destinations for links the reader may need to type /
  .content a[href^="http"]::after {
    content: " (" attr(href) ")";
    font-size: 9pt;
    word-break: break-all;       / long URLs will otherwise blow the measure /
  }
  / ...but never for nav, buttons or logos /
  nav a::after, .btn::after, a[href^="#"]::after { content: none; }
}

Two notes on that. The serif switch is a judgement call: on paper at 11pt, a well-set serif reads better than most UI sans faces, and the same measure and leading logic from web typography applies with different constants. If the brand is strict, keep the sans but bump size and leading.

The link-URL trick is widely copy-pasted and widely wrong. Printing every URL after every link turns an article into noise. Scope it to the content area, exclude internal anchors, and on documents like quotes or invoices, replace it with a single short URL or a QR code at the end instead.

Breaks, orphans, and what engines actually honour

break-inside: avoid on a table row, figure or code block is well supported in Chrome, Safari and Firefox print paths and is your main tool. break-after: avoid on headings works in Chrome and Safari. Firefox handles it inconsistently in nested contexts.

orphans and widows are the interesting pair. Chrome and Safari respect them in paged layout. Firefox has never implemented them. So if a document must be typographically exact, Firefox print is not your reference renderer. Pick one engine, usually Chromium, and state it as a requirement.

Named pages shipped in Chrome 110 and are genuinely useful for mixed documents:

@page landscape-table { size: A4 landscape; margin: 12mm; }

@media print {
  .wide-table { page: landscape-table; break-before: page; }
}

Where you’ll still lose: flex and grid fragmentation. Grid has improved in Chromium, but a grid container spanning a page break will still, often enough to matter, lose row gaps, duplicate borders or push an entire item to the next page leaving a half-page gap. Our rule on paginated documents is blunt: inside @media print, flatten flex and grid containers to display: block and use tables for genuinely tabular data. It feels like 2009. It fragments correctly. The grid versus flexbox decision you made for screen simply doesn’t carry over to paged media.

Generating PDFs server-side

If the PDF is a deliverable rather than a courtesy, stop relying on the user’s print dialog. You need a renderer you control.

  • Headless Chrome via Puppeteer or Playwright. Best CSS support, same engine your site already targets, easiest to debug. Heavy: a container with Chrome is hundreds of megabytes and needs memory headroom.
  • WeasyPrint. Python, no browser engine, genuinely good CSS Paged Media support including @page margin boxes and counter(page). It also writes PDF/UA-1 output with --pdf-variant pdf/ua-1, which matters if accessibility or archiving is in the contract. Weaker on modern layout and no JavaScript at all.
  • Prince (PrinceXML) and Antenna House. Commercial, excellent paged media fidelity, used in publishing. Price them before promising them.
  • Paged.js. A browser polyfill that implements Paged Media in JS so you can preview and build real books in Chrome. Brilliant for design review, slow on long documents.
  • wkhtmltopdf. Don’t. The project was archived and its WebKit fork predates flexbox gaps, modern grid and most of what you write today.

A Puppeteer setup that behaves predictably:

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch({ args: ['--no-sandbox'] });
const page = await browser.newPage();

await page.goto('https://app.example.com/invoices/8821/print', {
  waitUntil: 'networkidle0',   // not 'load': webfonts and images matter here
});

// Belt and braces: wait until the document declares itself ready
await page.waitForFunction('document.fonts.status === "loaded"');

const pdf = await page.pdf({
  preferCSSPageSize: true,   // let @page own size and margins, not this API
  printBackground: true,     // otherwise your table stripes and brand band vanish
  displayHeaderFooter: true,
  headerTemplate: '
', footerTemplate: `
Invoice 8821 Page of
`, }); await browser.close();

Three things people get wrong there. headerTemplate and footerTemplate inherit a default font size of about 8px and no padding, so you must style them inline or they look broken. Your @page bottom margin has to be large enough to hold the footer, or the footer overlaps body text. And preferCSSPageSize: true is nearly always what you want, because it keeps page geometry in the stylesheet where designers can see it rather than split across CSS and a Node config.

For invoices and quotes, also consider where the document is going. If a form submission needs to produce a PDF and land in email plus a CRM, the routing problem is separate from the rendering problem, and we’ve written about routing submissions to Slack, email and your CRM separately.

Testing css for printing without losing your afternoon

DevTools, Rendering panel, “Emulate CSS media type: print” shows you which rules apply. It does not paginate, does not honour @page, and will happily show you a perfect-looking page that breaks across four. Use it for cascade debugging only.

What actually works:

  1. Print to PDF, every time. Chrome’s “Save as PDF” destination, then read the whole file. Check page count, check the last page for a lone orphaned line, check page one for the letterhead.
  2. Toggle background graphics off. It’s off by default for most users. Your document must be legible without it.
  3. Test both A4 and Letter unless you’ve locked size in @page.
  4. Test greyscale. Set the printer to black and white, or add filter: grayscale(1) temporarily. Status badges that rely on red versus green become identical.
  5. Assert in CI. A Playwright test that renders the invoice route, generates the PDF and asserts a page count within an expected range catches regressions nobody would otherwise notice for months. Pair it with a visual diff of page one.
# Quick headless check from the terminal, no Node required
google-chrome --headless --disable-gpu \
  --print-to-pdf=/tmp/invoice.pdf \
  --no-pdf-header-footer \
  "https://app.example.com/invoices/8821/print"

WeasyPrint comparison render, with accessible tagging

weasyprint -v --pdf-variant pdf/ua-1 \ https://app.example.com/invoices/8821/print /tmp/invoice-wp.pdf

Scoping it so it doesn’t silently become your problem

Print work is almost never in the original brief, and it almost always arrives as an emergency. Two practical moves.

First, split the requirement. “Content pages print legibly” is a small, fixed-price piece of work: roughly a day on a typical marketing site, mostly hiding chrome and fixing clipping. “Pixel-accurate paginated invoice with running headers, page X of Y and PDF/UA tagging” is a different project with its own renderer, test suite and maintenance cost. Quoting them as one line item is how you end up building a typesetting engine for free.

Second, put a named print check in the recurring work. We list “print one representative page per template after any theme or plugin update” in our maintenance retainer scope, because a single plugin update that injects a sticky element or a new animation library will quietly break print and nobody will report it for a quarter.

Frequently Asked Questions

Should I use a separate print.css file or @media print inside my main stylesheet?

Use a separate file linked with media="print" for anything substantial. Browsers fetch print stylesheets at low priority and they don’t block render, so you get the organisational benefit without a performance cost. Keep small overrides (hiding a widget, a break-inside rule) next to the component they belong to, otherwise the print file drifts out of sync with the components it’s overriding.

Why is my PDF missing all background colours?

Chrome’s print dialog has “Background graphics” unchecked by default, and headless Chrome defaults printBackground to false. Add print-color-adjust: exact to the elements that need their fill, and pass printBackground: true in Puppeteer or Playwright. Even then, design so the document is readable with backgrounds stripped, because users printing from their own browser often leave the box unchecked.

How do I get “Page 1 of 7” in the footer?

Not from browser print. CSS @page margin boxes with counter(page) and counter(pages) are unimplemented in Chrome, Firefox and Safari. Your options are Puppeteer’s footerTemplate with the .pageNumber and .totalPages placeholder classes, a real paged-media engine like WeasyPrint or Prince, or the Paged.js polyfill if the output must render in a browser.

Can I rely on CSS Grid in print?

Partly. A grid that fits entirely on one page is fine. A grid that crosses a page boundary still fragments unpredictably in Chromium, usually by pushing an item to the next page and leaving a visible gap. For multi-page documents, override grid and flex to display: block inside the print stylesheet and use real tables for tabular data.

Does dark mode affect printing?

It can, badly. If your dark theme is driven by prefers-color-scheme and the user’s OS is set to dark, those custom properties apply when printing too, producing a page of solid dark ink. Reset your colour tokens explicitly inside @media print rather than assuming the light theme is the default, and test the print output with the OS in dark mode.

Where to start tomorrow

Pick your three highest-value printable pages: probably an invoice or quote, one long-form content page, and whatever your support team gets asked to email as a PDF. Print each to PDF right now, read every page, and write down what’s wrong.

If the list is short (chrome bleeding through, clipped tables, missing images), that’s an afternoon with the baseline stylesheet above and you’re done. If any of those pages is a legal or financial document that has to look identical every time, stop treating it as CSS housekeeping and scope a server-side renderer with a page-count test in CI. The difference between those two paths is the whole decision, and it’s much cheaper to make it now than during the week the PDF has to be right.

break-inside avoid page break css print css for printing web pages lazy loaded images not printing print stylesheet print stylesheet css examples print-color-adjust background graphics not printing puppeteer pdf page number footer template weasyprint vs puppeteer pdf generation