Table rows splitting across pages in a PDF
Start by checking whether it is happening. A long table printed by headless Chrome usually does not have a single row cut in half, and the advice everyone reaches for first fixes something that was never broken.
60 rows, two lines each, A4, 20mm top and bottom margins
no extra CSS 4 pages 0 rows cut headings on pages 1,2,3,4
break-inside: avoid 4 pages 0 rows cut headings on pages 1,2,3,4Same page count, no rows cut, with and without the declaration. If your table looks like that one, the problem you came here for is somewhere else: a heading stranded at the foot of a page, a total separated from the rows it sums, or a row that has moved rather than been broken. All three look like splitting and none of them is.
Every figure below came out of a rendered PDF read back page by page, on Chromium 151.0.7922.34, A4 with 20mm top and bottom margins. The version matters more than usual here, because one of the answers is that Chromium already does for you what you were about to ask it to do, and that is its decision rather than a standard.
How to tell, on your own PDF
Read the text out of the file instead of scrolling the pages. Poppler prints a form feed between pages, so a row that was cut has one part before it and the rest after:
pdftotext -layout invoice.pdf -...
41 Item 41 first line 512.50
Item 41 second line
42 Item 42 first line 525.00
<page break, a form feed in the output>
Item 42 second line
43 Item 43 first line 537.50Row 42 is the one to notice. If every row keeps its lines together and the break falls between two of them, nothing is being split, whatever the page looks like. That is the check the rest of this page is built on, run the same way against the same file.
A row is only cut when it cannot fit
Chromium moves a row to the next page when there is not enough room left for it. It breaks a row only when moving it will not help, which means when the row is taller than the space a page can offer. So the height of one row decides everything, and the length of the table decides nothing.
one row in three made taller, everything else identical
row height as written break-inside: avoid
2 lines 2 pages, 0 cut 2 pages, 0 cut
10 lines 3 pages, 1 cut 3 pages, 0 cut
25 lines 5 pages, 3 cut 8 pages, 0 cut
60 lines 10 pages, 8 cut 16 pages, 8 cutAt two lines a row always fits and is always moved. At ten lines it fits on a fresh page but not always in what is left of the current one, so one row in the whole document got broken. By twenty-five lines it happens three times. At sixty lines the row is taller than the page itself, and no amount of moving can save it.
This is why the symptom is so hard to reproduce from a bug report. Two documents with the same number of rows behave completely differently if one of them has a description field that occasionally runs long.
You do not need to ask for the headings to repeat
The most common answer to this question is to add thead { display: table-header-group }. It changes nothing, because that is already the value.
the same 4-page table, one declaration changed
thead untouched headings on pages 1,2,3,4
thead { display: table-row-group } headings on page 1The four-page table carries its column headings on all four pages with no stylesheet asking for it. Force the other value and they collapse to page one, which is what makes this a measurement rather than an assumption.
There is one case where writing it out earns its place: a stylesheet somewhere else has already set display on thead, usually to block as part of a responsive table pattern, and the repeat stopped when that rule arrived rather than when the table grew.
The declaration that does work, and what it costs
tbody tr {
break-inside: avoid;
}On the twenty-five line document that is three cut rows down to none. It is also five pages up to eight.
The cost is structural rather than a surprise. Keeping a tall row whole means leaving the bottom of a page empty whenever the next row will not fit, and a document with many tall rows leaves that gap many times. Sixty percent longer on the document measured here, which is a real number for a real invoice and worth knowing before it is applied to a run of a thousand.
Apply it to tbody tr rather than to tr, or the header row gets it too and cannot be repeated cleanly.
Above one page it stops working and keeps charging
This is the part that is missing from every answer to this question, and it is the case most likely to be yours, because a row nobody expected to be tall is exactly the row that breaks a layout.
At sixty lines per tall row the declaration changes the number of cut rows from eight to eight. It changes the document from ten pages to sixteen.
A break has to happen somewhere: the content does not fit on any page, so the browser ignores the request and breaks it anyway. What the browser does not ignore is the other half of the instruction, which is to start the row on a fresh page. So every tall row gets a page of its own to begin on, six extra pages appear, and not one of the breaks you were trying to prevent goes away.
The six pages come back if you stop asking for the impossible on the rows that cannot deliver it:
tbody tr { break-inside: avoid; }
/* Let the ones that cannot fit be broken rather than pushed. */
tbody tr.long { break-inside: auto; }the 60-line document, three ways
avoid on every row 16 pages 8 cut
avoid, auto on the long rows 10 pages 8 cut
avoid, auto on the long cells 16 pages 8 cutSixteen pages down to ten, with the eight breaks that were always going to happen. Note the third row of that table, because it is the version most people would write: the same exemption on the cell instead of the row does nothing at all. Whether a row may be broken is decided on the row.
The reliable fix above the CSS is to stop the row being that tall. A description that runs to sixty lines is usually a field that should have been truncated, a list that should have been its own table, or an address that arrived with newlines in it.
Three things that look like this and are not
A heading alone at the foot of a page. Nothing was split. Ask for it to stay with what follows: h2 { break-after: avoid }.
A totals block separated from its rows. The same declaration as the rows, on the block that has to stay together.
A row that moved. The bottom of a page is empty and the row is intact on the next one. That is the browser doing its job, and forcing it not to is how a document ends up with a page break in the middle of a sentence somewhere else.
How we handle this
PaperPony renders PDFs from HTML through an API, on the Chromium these figures were measured on, with the page size and margins used above as the defaults. The CSS on this page is yours to write and behaves in our renderer the way it behaves in yours, which is the point of measuring it rather than describing it.
The template guide carries these declarations alongside the ones for headers, footers and fonts, and the invoice recipe is a complete document with them already in it. The invoice generator needs no account and runs the same renderer, so it is the quickest way to see how a table of yours breaks.