DEV Community

Philipp Staudt
Philipp Staudt

Posted on Originally published at formfeed.dev AI-assisted

Page breaks in tables: what Chromium honours in 2026

A table that fits on one page is easy. The trouble starts at the page break: a header that does not come back, a row cut through the middle, a total printed under the first thirty rows, a footer lying on top of the last line. Most advice about this was written for browsers that paginated differently, and gets copied forward.

So we measured. We rendered 21 small table documents through our render worker — the same headless Chromium, version 153.0.8010.12, that prints every Formfeed PDF — and read the finished PDFs back with pdf.js to see which page every row, header and total landed on. Every case below links to its PDF.

The starting point

Every Formfeed document starts with a short print reset. Four of its lines are about tables (in effect; the real rules sit in :where() so that any rule of the template wins):

table { break-inside: auto; border-collapse: collapse; }
tr { break-inside: avoid; }
thead { display: table-header-group; }
tfoot { display: table-footer-group; }
Enter fullscreen mode Exit fullscreen mode

The tr line is ours, not Chromium's: without it, Chromium splits a row wherever the page ends. The cases use A4 with 20 mm margins, ten-point text, and a page-number footer from Chromium's footer template unless they say otherwise. Rows are numbered, and a tall row's lines are numbered too (08·5 is the fifth line of row 8), so a split shows.

What Chromium keeps

Case CSS What the PDF shows
60 rows — the header row repeats at the top of page 2 PDF
rows six lines tall tr { break-inside: avoid } 7 whole rows per page, none split PDF
the same rows tr { break-inside: auto } rows 8, 16 and 24 split across pages PDF
one row of 90 lines tr { break-inside: avoid } the row starts a new page and then splits anyway; all 90 lines are printed PDF
groups of six rows, each its own tbody tbody { break-inside: avoid } page 1 ends after row 30, a group boundary, instead of row 32 PDF
a heading at the foot of a page h2 { break-after: avoid } the heading moves to the next page with its table PDF
the last row alone on a page tbody tr:last-child { break-before: avoid } row 8 goes along: 7 rows, then 2 PDF
the last two rows as their own tbody tbody.tail { break-inside: avoid } the same, 7 and 2 PDF
the table inside a flex, grid, overflow: auto, overflow: hidden or absolutely positioned wrapper — paginates like a bare table, header repeated flex · grid · auto · hidden · absolute

The last row surprised us most, because every one of those wrappers turns up in advice on why a table stopped paginating. In Chromium 153 none of them stops it.

The two keep-together rules are the ones worth having in every template. Without them, the heading stays behind on its own:

A section heading alone at the foot of page 1, its table starting on page 2

and the last row of a table ends up alone on a page of its own:

Rows 1 to 8 on page 1, row 9 alone under a repeated header on page 2

With break-before: avoid on the last row, Chromium takes the row before it along, so no page holds a single row:

Rows 1 to 7 on page 1, rows 8 and 9 on page 2

And this is what a tall row looks like when nothing asks Chromium to keep it together — the amount stays on page 1 and the last two lines of the description go to page 2:

Row 8 split across two pages, its lines 5 and 6 under the repeated header on page 2

Three ways to get it wrong

The total on every page

tfoot looks like the place for a total. But a tfoot is a footer group, and Chromium repeats it at the foot of every page the table reaches, just as it repeats thead at the top. The grand total turns up under row 31 on page 1:

The total 7,200.00 printed at the foot of page 1, after row 31 of 60

Two fixes. Put the total in a last tbody of its own. Or keep the tfoot and turn it into an ordinary row group, tfoot { display: table-row-group; } — then it prints once, on the last page, as long as it comes after the tbody in the markup. HTML also allows the tfoot before the tbody; with the override, such a total prints above the first row.

The repeat cannot carry a running subtotal either. What Chromium repeats is a copy of the same content, so a "carried forward" line that sums the rows above it on each page is not something a tfoot can do.

A row cut in half

Wrap the table in display: inline-block — a common way to centre it or shrink it to its content — and Chromium stops paginating the table inside. It prints the box across the page edge as it is: row 33 is cut through the middle, and page 2 has no header.

Row 33 sliced horizontally at the foot of page 1, its lower half at the top of page 2, no header on page 2

A block wrapper does not do this. thead { display: block; }, which some scrolling-table CSS sets, keeps the rows whole but loses the repeated header.

The row under the footer

A footer made with position: fixed; bottom: 0 does repeat on every page. The table does not know it is there. Row 32 slides underneath, and since the table thinks row 32 fitted, page 2 carries on with row 33 — row 32 is never printed whole:

A fixed footer covering the lower half of row 32; page 2 starts at row 33

The clean fix is not to put the footer in the page at all. Chromium's own footer template is printed in the bottom margin, where content never reaches; every other case in this post has one, and none lost a row to it. If the footer has to live in the page, the repeat that caused the first problem solves this one: an empty tfoot row as tall as the footer repeats on every page and keeps the rows clear of it.

<tfoot><tr class="reserve"><td colspan="3"></td></tr></tfoot>
Enter fullscreen mode Exit fullscreen mode
tr.reserve td { height: 10mm; padding: 0; border: 0; }
Enter fullscreen mode Exit fullscreen mode

Row 31 is the last on page 1, clear of the fixed footer; page 2 starts at row 32

The short version

  • Keep rows whole with tr { break-inside: avoid }, and groups of rows with a tbody each and break-inside: avoid.
  • Keep headings with their table: h2 { break-after: avoid }.
  • Keep the last row company: tbody tr:last-child { break-before: avoid }.
  • Put totals in a last tbody, or make the tfoot a table-row-group after the tbody.
  • No inline-block around a table, no display: block on its thead.
  • Footers go in the footer template, not in position: fixed.

In Formfeed

The print reset above is on by default and every line of it can be overridden. The Footer tab of the editor is Chromium's footer template, so a footer made there sits in the margin. The editor's paged preview is built to lay out like the PDF, and True render sends the template through the same worker these cases went through. The page-break section of the docs has the reset, and the report example is a two-page document with a chart and a table of monthly figures.

Rendered and measured on 21 September 2026 with Chromium 153.0.8010.12. Chromium's layout changes between versions; we re-run the cases when the worker's Chromium does.

Top comments (3)

Collapse
 
to21as profile image
Tobias •

The wrapper row is the one I'd have got wrong. I would have blamed a flex or overflow wrapper long before an inline-block one, and in your Chromium 153 run inline-block is the only one that stops the table paginating. Reading the finished PDFs back with pdf.js to see which page each row landed on is what makes that checkable. I run a Chromium-based PDF API as well and have no test that pins rows to pages, so a Chromium bump could move a break without me noticing. Is the read-back wired into the worker's Chromium upgrade as a gate, or do you re-run the 21 cases by hand and compare?

Collapse
 
staudtit profile image
Philipp Staudt •

Same here: flex and overflow were the wrappers I expected to break it.

To your question: by hand. It is not a gate.

What exists is one script. It renders the 21 cases through the worker's Docker image, reads each PDF back and writes a results.json that is committed next to the PDFs: the Chromium version and, per case, the page count, how often header and footer appear on each page, the row range of each page, the rows that landed on two pages and the lines a fixed footer covered. Comparing after a bump is a git diff of that one file. The PDFs are no use for that, they differ in their timestamps on every run.

What makes this less loose than it sounds: Chromium cannot move without a commit. The worker runs on Playwright's image, and the image tag and the npm package are both pinned to an exact version, so a new Chromium arrives only in a change to those two lines, and that change is the cue to run the script. But it is a cue for a person. The pin has not moved since the post, so the procedure has not been through a real bump yet, and nothing fails if I forget.

The worker's tests in CI are where you are: real Chromium, page counts pinned for a few documents, no rows pinned to pages.

If you want to close the gap on your side, the part worth copying is small. Number the rows in the text (and the lines of a tall row, 08·5), and the measurement is pdf.js getTextContent() per page plus a regex: no pixels, no thresholds, and the expectation is a JSON file a test can compare. That is also all it would take to make ours a gate.

Collapse
 
staudtit profile image
Philipp Staudt •

Follow-up: it is a gate now. Your question was the push. Thanks! :D

The 21 cases moved into a small module that both the blog script and the worker's test suite import. A test renders each case through the same pipeline, reads the PDF back and compares it with the committed results.json. It runs in CI on every change, so a Playwright bump that moves a break fails before it ships. So does a change to our own print reset: switching off tr { break-inside: avoid } failed exactly the two cases that depend on it.

Two things that may save you time if you build the same:

  • pdf.js reads text in plain Node (pdfjs-dist/legacy/build/pdf.mjs, getTextContent()), with no browser and no canvas.
  • Make the cases independent of fonts: a line height that is a multiple of the font size, and no text that wraps. Then a row is the same height on every machine, and numbers measured in the Docker image hold on a CI runner with different fonts. Mine matched between Linux and Windows.

Cost: about two seconds per case on my machine.