CSS page breaks in HTML to PDF: what works in Chrome

You added page-break-before: always and nothing happened. Or the PDF is a single page with half the content gone. Chrome (and so Puppeteer, Playwright and most HTML to PDF APIs) supports the CSS break properties well now, but it silently ignores them in a handful of situations. We printed 33 test layouts with Chrome to find out exactly which.

What we measured

Each layout was printed to A4 with Chrome's page.pdf(), then every page was read back with pdftotext to see where each element landed.

LayoutResult
Cards with no break rules
12 cards, each about 70mm tall
2 of 12 cut in half
break-inside: avoid on the cards
also inside display: flex, flex-wrap and display: grid parents
0 cut, works
break-before: page on a block inside a flex or grid parentworks
break-before: page on a <span>, an inline-block or a <br>ignored
break-before: page on a floated or position: absolute elementignored
App shell: html, body { height: 100% } and a scrolling .app { overflow: auto }
same with height: 100vh or body { position: fixed }
1 page, 8 of 12 cards missing
<h2> that lands at the very bottom of a pageheading alone on page 1, its text on page 2
Same heading with break-after: avoidmoved to page 2 with its text
60-row table, header in <thead>header repeated on all 4 pages, 2 rows cut in half
Same table with tr { break-inside: avoid }0 rows cut
Header row inside <tbody> instead of <thead>header on page 1 only

Tested with Chromium 156 through our renderer (Playwright). The legacy page-break-before, page-break-after and page-break-inside properties behave the same: Chrome maps them to the break-* ones.

The two failures that cost the most

Content missing at the end. Single-page apps often make html and body 100% high and scroll inside a wrapper. Print keeps that box one viewport tall and clips the rest, so you get one page and no error. This is the usual cause of "Puppeteer PDF only prints the first page". Reset the heights and overflow in a print stylesheet (rule 1 below).

Breaks that do nothing. A forced break only applies to block-level elements in the normal flow. On an inline element, a float or an absolutely positioned box Chrome drops it without a warning. Put the break on a block wrapper, or give the element display: block and remove the float or positioning in print.

A print stylesheet that fixes all of it

Adjust the selectors (#root, .app, .card) to your markup. With this added, the broken test page below printed 3 pages with every element where it should be, instead of 1 page.

@media print {
  /* 1. Let the page grow: app shells that scroll inside a 100%-high box print one page. */
  html, body, #root, .app {
    height: auto !important;
    overflow: visible !important;
    position: static !important;
  }

  /* 2. Forced breaks only work on block-level, in-flow elements. */
  .page-break { display: block; break-before: page; }

  /* 3. Keep things whole. */
  .card, figure, pre, tr, img { break-inside: avoid; }

  /* 4. No heading stranded at the bottom of a page. */
  h1, h2, h3 { break-after: avoid; }

  /* 5. Repeat table headers on every page. */
  thead { display: table-header-group; }
}

Use <div class="page-break"></div> wherever a new page should start; an empty block with a forced break works fine. break-after: avoid on headings is respected by current Chrome; older versions ignored it, which is why many answers online say it doesn't work.

Find the problems automatically

This script loads a page in print mode and lists every forced break Chrome will ignore and every box that clips its content. Run it in CI before you ship a template: npm i puppeteer, save as check-breaks.mjs, then node check-breaks.mjs report.html. It exits with 1 when it finds something.

import puppeteer from 'puppeteer';
import { pathToFileURL } from 'node:url';

// Usage: node check-breaks.mjs page.html   (or a URL)
const arg = process.argv[2] ?? 'page.html';
const target = arg.startsWith('http') ? arg : pathToFileURL(arg).href;

const browser = await puppeteer.launch({ args: ['--no-sandbox'] }); // --no-sandbox: Ubuntu 23.10+ / Docker
const page = await browser.newPage();
await page.goto(target, { waitUntil: 'networkidle0' });
await page.emulateMediaType('print'); // check the layout that page.pdf() will print

const problems = await page.evaluate(() => {
  const out = [];
  const name = el => el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') +
    [...el.classList].map(c => '.' + c).join('');
  for (const el of document.querySelectorAll('*')) {
    const s = getComputedStyle(el);
    const forced = ['page', 'left', 'right'].some(v => s.breakBefore === v || s.breakAfter === v);
    if (forced && s.display.startsWith('inline')) out.push(`${name(el)}: page break ignored (display: ${s.display})`);
    else if (forced && s.float !== 'none') out.push(`${name(el)}: page break ignored (float: ${s.float})`);
    else if (forced && ['absolute', 'fixed'].includes(s.position)) out.push(`${name(el)}: page break ignored (position: ${s.position})`);
    const clipped = el.scrollHeight - el.clientHeight;
    if (['auto', 'scroll', 'hidden'].includes(s.overflowY) && el.clientHeight > 0 && clipped > 1)
      out.push(`${name(el)}: ${clipped}px cut off by overflow-y: ${s.overflowY} (only the visible part prints)`);
  }
  return out;
});

console.log(problems.length ? problems.join('\n') : 'No page-break problems found');
await browser.close();
process.exitCode = problems.length ? 1 : 0;

On a test page with all three mistakes it prints:

div.app: 846px cut off by overflow-y: auto (only the visible part prints)
span.chapter: page break ignored (display: inline)
div.side: page break ignored (float: right)

After adding the print stylesheet it prints No page-break problems found. Both runs were done with Puppeteer and Chrome for Testing 155.

With the Shotmint API

/v1/pdf prints with Chrome in print media, so all of the above applies unchanged: put the print CSS in your HTML. This sends a local file (needs jq):

curl -s -X POST https://shotmint.creasework.com/v1/pdf \
  -H "X-API-Key: $SHOTMINT_KEY" -H "Content-Type: application/json" \
  -d "$(jq -n --rawfile html report.html '{html: $html, format: "A4", margin: "15mm"}')" \
  -o report.pdf

Run against the live API with the fixed test page: a 3-page A4 PDF, 1 credit. Add "screen_media": true to print with your screen styles instead, and see the page numbers guide for headers and footers.

Page-break checklist

Related: HTML to PDF invoices, Tailwind print classes, lazy-loaded images in PDFs, HTML to PDF in Node.js.

What it costs

One PDF = one credit, however many pages it has. €5 buys 2,000 renders, credits never expire and there's no subscription.

Try it with 25 free renders a month

No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire). From a script, an AI agent, Make or Zapier, POST /v1/keys/free (no body) returns a key as JSON (openapi.json, no-code recipes).

Get a free API key Full API reference

More guides: HTML to PDF and HTML to image

All guides: guides · integrations · comparisons