🍱 Lunchbox Hands

css

CSS Page Breaks: Why break-inside: avoid Is Not Working in Your PDF

Why HTML-to-PDF output splits tables, images and headings mid-page, and how to control it: page-break-* vs break-*, why avoid silently fails, orphans, widows, @page and thead, with browser support checked against MDN data.

You add page-break-inside: avoid to a table, export to PDF, and the table still breaks across two pages with a single row stranded at the bottom. You switch to break-inside: avoid, and nothing changes. Page-break CSS isn’t a command. It’s a request the fragmentation engine is allowed to drop, and it only reaches that engine if your PDF goes through the browser’s print pipeline in the first place. Most “not working” reports come down to one of two things: the rule never reached a paginator at all, or the paginator had to break the rule because nothing else fit. Below is how each case happens, with every support claim checked against MDN’s browser-compat-data (release 8.1.3, September 2026) and the CSS Fragmentation spec.

First: is your PDF printed or painted?

There are two unrelated ways to turn HTML into a PDF, and break properties only mean something in one of them.

PipelineExamplesHonors break-*, @page, orphans?Text selectable?
Print engine: the browser lays the document out into pageswindow.print() → Save as PDF, headless Chromium’s PDF exportYes, within each engine’s supportYes
Raster: screenshot one tall page, cut it into stripshtml2canvas + jsPDF, html2pdf.jsNo. Nothing reads themNo

A raster pipeline renders your document as one long image and then cuts it at fixed intervals. No layout engine takes part in the cut, so no CSS property can move it. If your tool is built this way, stop editing your stylesheet. The fix has to happen somewhere else.

Both of our converters are honest examples, one of each kind:

  • Markdown to PDF writes a styled document into a new window and calls window.print(). You choose “Save as PDF” in the print dialog, so everything in this article applies, and the text in the result is real text.
  • HTML to PDF renders your markup in a sandboxed frame, captures it with html2canvas, and slices the capture into pages with jsPDF. Page-break CSS has no effect there. How to work with the cut comes later.

page-break-* and break-* are the same thing

The old properties weren’t removed. The Fragmentation spec redefines page-break-before, page-break-after and page-break-inside as legacy shorthands for the modern ones, and MDN says browsers treat them as aliases:

Legacy valueModern equivalent
page-break-before: alwaysbreak-before: page
page-break-after: alwaysbreak-after: page
page-break-*: avoidbreak-*: avoid
page-break-*: left / right / autosame keyword
page-break-inside: avoidbreak-inside: avoid

Note that always maps to page, not to the Level 4 always keyword. Browsers had always implemented it as a page break, never a column break. The practical takeaway is that switching from page-break-inside to break-inside never fixes anything on its own. If one of them fails, the other fails for the same reason. Write the modern property. Keep the legacy one only if an old renderer (wkhtmltopdf’s long-frozen QtWebKit, for instance) is in your pipeline.

Five reasons break-inside: avoid silently fails

1. The element is taller than a page

avoid asks the engine to prefer a break somewhere else. If the element can’t fit on any page, there is no somewhere else. The spec’s fallback rules drop the avoid constraints one by one, and in the end “the UA may break anywhere in order to avoid losing content off the edge of the fragmentainer.” A 60-row table with break-inside: avoid will still split. Put the rule on the rows instead:

tr, img, figure, pre, blockquote { break-inside: avoid; }

2. The element can’t carry the property

By the spec, break-inside applies to everything except inline-level boxes, absolutely positioned boxes, and table columns and column groups. So an inline-block card, a <span>, or anything with position: absolute ignores it. A card grid built from display: inline-block is the classic case. Make the item a block, flex or grid item.

3. Flex, grid and tables in an older engine

Chrome’s old layout engine printed by laying the whole document out as one tall strip and slicing it at paint time. The RenderingNG fragmentation write-up says plainly that it had “no flex or grid fragmentation support at all”, and table fragmentation was limited. The rewrite shipped in stages:

Chrome versionWhat began fragmenting properly
102Block containers, lines, floats, positioned elements
103Flex and grid
106Tables
108Printing moved to the new engine

Firefox had its own flex-printing bug, where an avoid-inside flex item could overlap the content after it. It was closed as a duplicate of a fix confirmed in Firefox 122 Nightly (bug 1695475). If your HTML-to-PDF service runs a pinned or bundled browser, check its version before you debug your CSS. A headless Chromium older than 108 still uses the old slicing.

4. You used the column- or page-specific keyword

break-inside: avoid-page works in Chrome 51+ and Firefox 92+, but MDN records Safari as not supporting it for pages. Plain avoid has the widest support: Chrome 50, Firefox 65, Safari 10.

5. The pipeline is raster

Covered above, but it deserves a second mention because it’s the most common cause. If the text in your PDF can’t be selected, no CSS break rule was ever read.

Orphaned headings: break-after: avoid only works in Chrome

A heading stuck alone at the bottom of a page, with its paragraph on the next one, is the complaint everyone recognizes. The spec’s fix is one line:

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

It only works in one engine. MDN’s compat data records break-after: avoid as supported in Chrome 50+. In Firefox and Safari the value “is recognized, but has no effect” (Firefox bug 1972340, WebKit bug 294559). Even in Chrome, the old engine didn’t honor avoid between blocks at all, so a pre-108 Chromium ignores it too.

The workaround that holds everywhere uses break-inside, which all three engines honor. Wrap the heading and its first block together:

<div class="keep">
  <h2>Quarterly totals</h2>
  <p>Revenue grew in every region except…</p>
</div>
.keep { break-inside: avoid; }

Keep the wrapper short. If it grows taller than a page, you’re back to reason 1.

Orphans and widows: Chrome and Safari only

orphans and widows set the minimum number of lines of a paragraph that must stay at the bottom of a page (orphans) or carry over to the top of the next (widows). Both default to 2.

PropertyChromeFirefoxSafari
orphans25+Not supported (bug 137367)1.3+
widows25+Not supported1.3+
p { orphans: 3; widows: 3; }

They apply to block containers with inline content, meaning paragraphs, list items and cells, and they count lines, not elements. In Firefox a paragraph can leave a single line behind whatever you set.

Table headers that repeat on every page

A table’s <thead> is display: table-header-group by default, and a print engine that fragments tables properly repeats it at the top of each page it spans. <tfoot> repeats at the bottom. Chrome and Firefox both do this. Safari is the exception: WebKit bug 17205, “THEAD & TFOOT should be printed on every page”, was filed in February 2008 and was still open at its last update, with a comment noting that WebKit is the only engine left without it.

Two practical rules:

  • Use real <thead> markup. A first row of <td>s styled bold is just a row and will never repeat.
  • Pair it with tr { break-inside: avoid; } so no row is split between two pages, header or not.

@page: paper size, margins and margin boxes

@page controls the page box itself. Its support is much more uneven than people expect:

FeatureChromeFirefoxSafari
@page { margin }2+19+18.2+
size: A4 / size: 8.5in 11in15+95+18.2+
size: landscape keyword15+95+No
Margin boxes (@top-center, @bottom-right…)131+NoNo

Safari ignored @page completely until 18.2. Margin boxes are the modern way to put running headers and page numbers into CSS, and only Chromium supports them:

@page {
  size: A4;
  margin: 18mm 16mm;
  @bottom-center { content: "Page " counter(page) " of " counter(pages); }
}

Chrome’s own margin-box article adds a catch: when printing from the browser, it will still add its own header and footer (date, URL, title) wherever there’s room, “even if you have added content”. Turn off “Headers and footers” in the print dialog. If you need page numbers that work in every browser, add them after export with Add Page Numbers to PDF.

Backgrounds that vanish: print-color-adjust

To save ink, browsers drop background colors and images when printing unless told otherwise. Zebra-striped rows, shaded table headers and dark code blocks come out white.

th, .callout, pre {
  -webkit-print-color-adjust: exact;
  print-color-adjust: exact;
}

The unprefixed property shipped in Firefox 97, Safari 15.4 and, relatively late, Chrome 136. Before that, Chrome only knew the -webkit- prefix (since Chrome 17), so write both. One quirk from MDN’s notes: Chrome doesn’t print the <body> element’s own background even with exact. Put the color on a wrapper instead.

A print stylesheet that fixes most of it

@media print {
  @page { size: A4; margin: 16mm; }

  h1, h2, h3, h4 { break-after: avoid; }   /* Chrome only */
  .keep          { break-inside: avoid; }  /* heading + first block wrapper */
  tr, img, figure, pre, blockquote { break-inside: avoid; }
  thead { display: table-header-group; }   /* default, but restores it if a reset changed it */
  p { orphans: 3; widows: 3; }             /* Chrome and Safari */

  .page-break { break-before: page; }
  th, pre { -webkit-print-color-adjust: exact; print-color-adjust: exact; }

  img { max-width: 100%; }                 /* an image wider than the page can't be avoided */
}

Test it in the print preview of every engine you care about. The same stylesheet produces different page counts in Chrome, Firefox and Safari. That isn’t a bug. It follows directly from the support tables above.

What our Markdown converter does

Markdown to PDF builds a print document with @page { size; margin } taken from your paper and margin choices, sets print-color-adjust: exact on the page so shaded table headers and code blocks survive, and adds page-break-after: always (which aliases to break-after: page) after the optional title page. Markdown tables become real <thead>/<tbody> markup, so the header row repeats on each page in Chrome and Firefox. In Safari it doesn’t, because of the WebKit bug above.

What it doesn’t do, currently, is add avoid rules of its own. Headings, code blocks and table rows get the browser’s defaults, which means Chrome and Safari still apply orphans: 2/widows: 2 to paragraphs, while nothing keeps a heading attached to its paragraph. For a document where that matters, run the Markdown through Markdown to HTML, add the stylesheet above, and print the HTML file from your browser.

Working with a raster converter

HTML to PDF is the other kind. It deliberately trades selectable text for pixel-faithful rendering of arbitrary CSS, and it says so on the page. Its pagination is pure arithmetic. The capture width is mapped to the page’s content width, and the image is cut every content-height worth of pixels. At 96 CSS px per inch, that puts the cuts at predictable CSS pixel offsets:

Page and marginContent widthCut every
Letter, 0.5” margins720px960px
Letter, 1” margins624px864px
A4, 0.5” margins≈698px≈1026.5px
A4, no margin≈794px≈1122.5px
Legal, 0.5” margins720px1248px

If you control the markup, you can design for those numbers: fixed-height sections, or spacer blocks that push a table start past the next multiple. Horizontal overflow changes the numbers, though. A table wider than the frame widens the capture, and everything scales down to fit. If you need real page-break control, use the print route instead: open your HTML file in a browser and choose Save as PDF.

Researching this post also turned up a small bug in that slicing math. The cut position advanced by a fractional pixel height (960.0 is exact, but most page sizes aren’t), and the accumulated floating-point error could leave a phantom remainder of about 0.0000000000005px. That produced an extra, blank, one-pixel-tall last page on some Letter-size documents. The slicer now requires at least one whole pixel row before it starts a new page, and regression tests cover both the drift case and a genuine one-pixel remainder.

If your PDF comes out right but the text layer matters, for search, accessibility, or a resume that has to pass an applicant tracking system, the print route is the only one of the two that gives you real text. For the post-processing steps, merging, compressing and numbering pages all run in the browser too.