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; }
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 | |
| rows six lines tall | tr { break-inside: avoid } |
7 whole rows per page, none split | |
| the same rows | tr { break-inside: auto } |
rows 8, 16 and 24 split across pages | |
| one row of 90 lines | tr { break-inside: avoid } |
the row starts a new page and then splits anyway; all 90 lines are printed | |
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 | |
| a heading at the foot of a page | h2 { break-after: avoid } |
the heading moves to the next page with its table | |
| the last row alone on a page | tbody tr:last-child { break-before: avoid } |
row 8 goes along: 7 rows, then 2 | |
the last two rows as their own tbody
|
tbody.tail { break-inside: avoid } |
the same, 7 and 2 | |
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:
and the last row of a table ends up alone on a page of its own:
With break-before: avoid on the last row, Chromium takes the row before it along, so no page holds a single row:
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:
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:
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.
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:
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>
tr.reserve td { height: 10mm; padding: 0; border: 0; }
The short version
- Keep rows whole with
tr { break-inside: avoid }, and groups of rows with atbodyeach andbreak-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 thetfootatable-row-groupafter thetbody. - No
inline-blockaround a table, nodisplay: blockon itsthead. - 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 (0)