DEV Community

Cover image for How to Control Page Breaks in HTML to PDF Output
Accreditly
Accreditly

Posted on • Originally published at html2img.com

How to Control Page Breaks in HTML to PDF Output

You render an invoice that looks right in the browser, open the PDF and find line item 14 cut in half by a page boundary. The description sits at the foot of page one and the amount sits at the top of page two. Or a certificate splits down the middle. Or a section heading is stranded alone at the bottom of a page while its content starts overleaf.

Chrome is doing exactly what it was asked to do: fill a page, start another, repeat. It has no opinion about which parts of your document belong together. You supply that opinion in CSS, and it takes about six lines. This post originally appeared on the HTML to Image blog as How to control page breaks in HTML to PDF output.

Everything here applies to any Chrome based pipeline: Puppeteer's page.pdf(), Playwright, or a rendering API. The properties are standard CSS, not vendor extensions.

The three properties that do the work

There is no avoid_splitting_rows flag on any HTML to PDF tool worth using, because the renderer is a browser and browsers already have a standard for this. CSS Fragmentation gives you three properties:

Property What it does Values you will actually use
break-inside Controls splitting within an element avoid, auto
break-before Forces or prevents a break before an element page, avoid, auto
break-after Forces or prevents a break after an element page, avoid, auto

The older page-break-inside, page-break-before and page-break-after still work. The spec defines them as legacy aliases and Chrome maps them onto the modern equivalents, so an existing stylesheet does not need rewriting. New code should use the short forms.

These properties do nothing on screen. Continuous media has no page boundaries to avoid, so the browser computes them and moves on. They only mean something once the content is being cut into pages.

Put them in your normal stylesheet, not in @media print

This is the bit that catches people out.

A rule hidden inside @media print only fires if the renderer is actually emulating print media. Puppeteer's page.pdf() does emulate print, so @media print blocks apply there. Plenty of rendering APIs do not, including the one I use, which renders PDFs with your screen CSS so the PDF matches the PNG of the same input.

The fragmentation properties do not care either way. They are ordinary declarations that sit in the cascade like any other, get ignored on continuous media, and take effect when the document paginates. So write them unconditionally:

/* Applied when the document paginates. Inert in the browser. */
.invoice-row,
.line-items tr,
.card,
figure {
  break-inside: avoid;
}
Enter fullscreen mode Exit fullscreen mode

Nothing about the page changes in a browser tab. Here is what it buys you in the PDF: six 300px cards with no rules at all fill each page to the edge and split whichever card straddles the boundary. Add break-inside: avoid to .card and the renderer fits three per page, leaves the leftover space at the foot of the page and starts card four cleanly on the next one.

Keeping an invoice together

Line items are the classic failure. A row that splits is not just ugly, it is a document where a quantity and its price appear on different pages, which is the sort of thing an accounts department notices before it notices your typography.

.line-items tr {
  break-inside: avoid;
}

/* Never leave a heading alone at the foot of a page */
h2,
h3 {
  break-after: avoid;
}

/* Totals, payment terms and the signature block stay as one unit */
.totals,
.payment-terms,
.signature {
  break-inside: avoid;
}
Enter fullscreen mode Exit fullscreen mode

break-after: avoid on headings is the quiet win. It tells the renderer that a heading may not be the last thing on a page, so if the following block will not fit, the heading travels with it. Two declarations remove an entire class of report that looks like it was assembled by accident.

Forcing a break where you want one

break-before: page starts a new page unconditionally. It is how you get one certificate per attendee, one statement per customer or one section per page out of a single render:

<style>
  .sheet { break-before: page; }
  .sheet:first-child { break-before: auto; }
</style>

<div class="sheet">…certificate for Priya…</div>
<div class="sheet">…certificate for Tom…</div>
<div class="sheet">…certificate for Andreas…</div>
Enter fullscreen mode Exit fullscreen mode

The :first-child reset matters. Without it the first sheet forces a break before itself and you ship a PDF with a blank first page. I have seen that one survive into production more than once.

It also turns a batch job into a single render. One request that returns a 40 page document beats 40 requests, and every page is still real vector text rather than a picture of a certificate.

Do not assume table headers repeat

Chrome is supposed to redraw <thead> at the top of every page a table spans. Test that assumption against your own pipeline before you rely on it. When I measured this on the renderer I use, the header appeared once at the start and every page after that carried bare rows.

The fix is to chunk in your template rather than hope in your CSS. Split the rows into page sized groups and give each group its own table:

const PER_PAGE = 24;

const chunks = [];
for (let i = 0; i < rows.length; i += PER_PAGE) {
  chunks.push(rows.slice(i, i + PER_PAGE));
}

const html = chunks.map((chunk, index) => `
  <table class="ledger${index > 0 ? ' continued' : ''}">
    <thead>
      <tr><th>Date</th><th>Reference</th><th>Description</th><th>Amount</th></tr>
    </thead>
    <tbody>
      ${chunk.map(r => `
        <tr>
          <td>${r.date}</td><td>${r.ref}</td><td>${r.description}</td><td>${r.amount}</td>
        </tr>`).join('')}
    </tbody>
  </table>
`).join('');
Enter fullscreen mode Exit fullscreen mode
.ledger.continued {
  break-before: page;
}

.ledger tr {
  break-inside: avoid;
}
Enter fullscreen mode Exit fullscreen mode

You now control exactly where the table breaks, every page carries its own header, and you can label the continued ones. Pick PER_PAGE by rendering once and counting, then leave it alone.

Running footers with fixed positioning

Chrome repeats fixed position elements on every page of a printed document, which gives you a running footer without the @page margin box machinery that browsers never implemented:

.doc-footer {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  padding: 12px 24px;
  border-top: 1px solid #e2e8f0;
  font-size: 12px;
  color: #64748b;
}

/* Reserve the space so content never slides under it */
body {
  padding-bottom: 64px;
}
Enter fullscreen mode Exit fullscreen mode

That covers a company registration number, a document reference or a confidentiality line on every page.

What you cannot get this way is a page number. Page counters live in @page margin boxes, which Chrome has never supported, so content: counter(page) renders nothing. Number the sheets yourself in the template where you already know how many there are, or reach for a print focused engine like Paged.js or a dedicated PDF service.

Flex and grid children fragment fine now

There is an old reflex that says flexbox and grid children ignore break-inside, so people rewrite a working card layout in floats or tables before generating a PDF. That reflex is out of date. Modern Chrome fragments both properly, and a column of flex children or grid items with break-inside: avoid paginates the same way block elements do: the item that will not fit moves to the next page whole.

I tested this rather than trusting my memory of it, and so should you, because the answer changed somewhere around the LayoutNG work and most of the advice online predates that.

The starter stylesheet

Drop this into any document you intend to render as a PDF and most pagination complaints disappear before anyone files them:

tr,
figure,
blockquote,
.card,
.totals,
.signature {
  break-inside: avoid;
}

h1, h2, h3, h4 {
  break-after: avoid;
}

.sheet {
  break-before: page;
}

.sheet:first-child {
  break-before: auto;
}
Enter fullscreen mode Exit fullscreen mode

Then render the real document, not a sample of three rows, and look at every page boundary. Pagination bugs only appear at length, which is why they tend to ship.

The full version of this post, including the notes on page size and what happens to content wider than the page, is on the HTML to Image blog: How to control page breaks in HTML to PDF output.

What is your worst PDF pagination bug? Mine is still the blank first page that nobody noticed for two months. Share yours in the comments.

Top comments (0)