CSS for print — @page, margins, page-breaks, the complete guide cover illustration

CSS for print — @page, margins, page-breaks, the complete guide

If you’re rendering HTML to PDF in 2026 and you’re not writing explicit @page rules, you’re leaving a lot of layout control on the table. This post is the deep-dive reference I keep open while I work: every CSS property that affects PDF output, what Chromium actually honours vs. what the spec says, and the pragmatic decisions you make in real invoice/report/certificate templates.

It’s long. It’s organised so you can jump to the part you need and leave. Use the sidebar ToC.

TL;DR

  • @page { size: A4; margin: 20mm } — declare the page. Chromium honours size, margin, orphans, widows, page-break-*, break-*.
  • break-inside: avoid — stop Chromium splitting tables, images, and cards across pages.
  • @media print { ... } — override screen-only styles (nav, shadows) for PDF output. Not required by default; useful for sharing CSS with a live site.
  • Page numbers and running headers — Chromium’s default PDF path doesn’t render @page margin-box content. Use your API’s header/footer template feature, or switch to Prince XML.
  • Use mm/cm/pt for physical dimensions, px for element sizing. 1 inch = 25.4 mm = 72 pt = 96 CSS pixels.
  • Print-colour-adjust: exact forces Chromium to render background colours, which it drops by default.

The foundation: @page

The @page rule is how you control the PDF’s physical page. It’s part of CSS Paged Media Level 3 and the core of every print stylesheet.

@page {
  size: A4;
  margin: 20mm 15mm 20mm 15mm; /* top right bottom left */
}

That’s it for most documents. You’ve declared the paper size and margins; the rendering engine will paginate the body against this frame.

size values

The size property accepts:

  • Named page sizes: A3, A4, A5, A6, B4, B5, Letter, Legal, Tabloid, ledger.
  • Dimensions: size: 210mm 297mm (A4 explicitly), size: 8.5in 11in (Letter).
  • Orientation keyword: size: A4 landscape, size: A4 portrait.

Chromium’s PDF path honours all three. If you pass options.page_size: "A4" to an HTML-to-PDF API and declare @page { size: Letter }, the CSS wins — this is standard CSS cascade behaviour.

Margins

Margins use CSS shorthand like margin on a regular element:

@page {
  margin: 20mm;                     /* all sides */
  margin: 20mm 15mm;                /* vertical horizontal */
  margin: 20mm 15mm 25mm;           /* top horizontal bottom */
  margin: 20mm 15mm 25mm 15mm;      /* top right bottom left */
}

Units matter here. Use mm, cm, in, or pt — these are physical units that map directly to paper dimensions. Avoid px in @page margins; Chromium will interpret 10px as ~2.6mm which is fine but reads badly in code.

Named pages and :first / :left / :right

You can have multiple @page rules scoped by pseudo-class or name:

@page { margin: 20mm; }
@page :first { margin-top: 40mm; }     /* title page gets extra top */
@page :left { margin-left: 25mm; }     /* double-sided binding */
@page :right { margin-right: 25mm; }

/* Named pages */
@page wide { size: A3 landscape; }
.chart-container { page: wide; }       /* use the @page wide rule */

Chromium’s PDF output honours :first, :left, :right, and the page property for named pages. Double-sided binding margins are actually useful for documents that will be physically printed and bound.

Page breaks: the break-* family

This is the CSS you’ll use most often. The properties are:

  • break-before — force or avoid a break before the element
  • break-after — force or avoid a break after the element
  • break-inside — allow or avoid a break inside the element

Values: auto (default), avoid, page, always, left, right, recto, verso, avoid-page.

The four patterns you actually need

1. Keep a block together (no split):

.invoice-row, .card, figure, pre {
  break-inside: avoid;
}

Use this for tables rows, cards, figures, code blocks, anything that reads wrong when cut in half. This is by far the most common page-break rule you’ll write.

2. Force a new page before a section:

h1.chapter {
  break-before: page;
}

Used in long documents (ebooks, reports) to start chapters on a fresh page.

3. Avoid breaking right after a heading (keep heading with its content):

h2, h3 {
  break-after: avoid;
}

Prevents orphan headings — a heading at the bottom of page 1 with all its content on page 2.

4. Force a new page after a cover:

.cover {
  break-after: page;
}

Standard for title pages, executive-summary pages, etc.

Legacy page-break-* equivalents

For older CSS (CSS 2.1 / Fragmentation Module Level 1):

/* Modern — prefer these */
break-before: page;
break-after: page;
break-inside: avoid;

/* Legacy — Chromium accepts both */
page-break-before: always;
page-break-after: always;
page-break-inside: avoid;

Both work in modern Chromium. Use the modern form; legacy is only worth knowing for old codebases you haven’t migrated yet.

Common page-break bugs

Breaks landing mid-row in a table. Fix:

table { break-inside: auto; }  /* allow tables to split across pages */
tr    { break-inside: avoid; } /* but not individual rows */

Break lands mid-paragraph mid-sentence. Use orphans / widows:

p {
  orphans: 3;  /* min lines at END of page */
  widows: 3;   /* min lines at START of next page */
}

Chromium defaults to 2/2; bumping to 3/3 gives much better-reading pagination on long prose.

break-inside: avoid ignored on a flex container. Flex and grid have imperfect break support in Chromium. If a flex container needs to stay together, wrap it in a plain block and apply break-inside: avoid to the wrapper:

.wrapper {
  break-inside: avoid;
}
.wrapper > .flex-container {
  display: flex;
  /* ... */
}

@media print — overrides for PDF output

By default, Chromium’s PDF output uses your regular screen CSS. You don’t need @media print to get a reasonable PDF.

You use @media print to change things specifically for PDF output:

@media print {
  nav, .site-header, .no-print {
    display: none;
  }

  a {
    color: #000;
    text-decoration: underline;
  }

  /* Expand link targets after their text for print */
  a[href]::after {
    content: " (" attr(href) ")";
    font-size: 0.9em;
    color: #555;
  }

  .shadow, .drop-shadow {
    box-shadow: none;
  }

  body {
    background: none;
    color: #000;
  }
}

When to use @media print: when your HTML is shared between a live web page and a PDF render, and some styles make sense only for screen (hovers, shadows, sticky nav) or only for print (black-and-white text, inline URLs).

When to skip it: when you’re writing a dedicated invoice/report template that will only ever be rendered to PDF. Just write the styles at the top level; cleaner and avoids the specificity maze.

Page numbers and running headers

This is where reality diverges sharply from what the CSS spec promises.

What the spec says

CSS Paged Media Level 3 defines margin boxes — 16 named positions inside the page margin (@top-left, @top-center, @top-right, @bottom-left-corner, etc.) that can hold content generated by CSS:

@page {
  @bottom-right {
    content: "Page " counter(page) " of " counter(pages);
    font-size: 10pt;
    color: #666;
  }

  @top-left {
    content: "Acme Industries · Tax Invoice";
    font-size: 9pt;
  }
}

This should produce page numbers and headers automatically. It does in Prince XML. It does not in Chromium’s default PDF output path.

What Chromium actually does

Chromium parses margin boxes without error but doesn’t render them in the PDF. Instead, every HTML-to-PDF API that uses Chromium exposes some flavour of header_template / footer_template option at the API level:

{
  "html": "...",
  "options": {
    "header_template": "<div style='font-size:9pt;text-align:right;width:100%;padding-right:15mm'>Page <span class='pageNumber'></span> of <span class='totalPages'></span></div>",
    "footer_template": "<div>...</div>"
  }
}

The header/footer template is a separate HTML snippet that Chromium renders in the page margin. The <span class="pageNumber"> / <span class="totalPages"> tokens are special Chromium placeholders it substitutes at render time.

21pdf is honest about this: we don’t yet ship a header/footer template option — the /docs/convert page labels it “roadmap”. The supported workaround is a position: fixed footer in the body HTML, which does repeat on every page:

.page-footer {
  position: fixed;
  bottom: 10mm;
  left: 15mm;
  right: 15mm;
  text-align: right;
  font-size: 9pt;
  color: #666;
}
<body>
  <div class="page-footer">Acme · page footer text</div>
  <!-- rest of content -->
</body>

This works but has limitations:

  • Can’t reference counter(page) (Chromium doesn’t expose the counter to body content)
  • Takes up visible space in the main content flow (mitigated by setting @page margin-bottom to reserve the area)

If you need real page numbers and your vendor supports header_template, use that. If your vendor doesn’t, position: fixed covers 80% of real use cases.

Counters in body content (this works)

Page counters don’t work in Chromium’s body, but regular CSS counters do. Useful for chapter numbering:

body { counter-reset: chapter; }
h1.chapter {
  counter-increment: chapter;
}
h1.chapter::before {
  content: "Chapter " counter(chapter) " — ";
  color: #666;
}
<h1 class="chapter">Introduction</h1>
<h1 class="chapter">The Argument</h1>
<h1 class="chapter">Proof</h1>

Output: “Chapter 1 — Introduction”, “Chapter 2 — The Argument”, etc. This is CSS-native and renders identically on screen and in the PDF.

Backgrounds, colours, and print-color-adjust

By default, Chromium’s PDF output drops background colours and images. It’s a print convention inherited from decades of paper/ink economy. On a tax invoice with a grey header row, this is almost never what you want.

Fix:

.invoice-header, td, th {
  print-color-adjust: exact;
  -webkit-print-color-adjust: exact; /* legacy prefix, still needed */
}

print-color-adjust: exact tells the renderer to honour the declared colour exactly, background included. -webkit-print-color-adjust is the same thing with a prefix that Chromium still reads.

Apply this:

  • On elements with background colours or images you want preserved
  • Globally on * if every background in your template should render (simpler but a bigger hammer)
* {
  print-color-adjust: exact;
  -webkit-print-color-adjust: exact;
}

I use the global version for invoices and reports — backgrounds are usually intentional in those templates. For prose-heavy documents, per-element is more defensible.

Fonts

Chromium will use whatever fonts your HTML declares. Three things to know:

1. Wait for fonts before rendering

Fonts are fetched from CSS @font-face URLs. If your API captures the PDF before the fetch completes, Chromium falls back to a system font. Always set wait_for_network_idle: true in your API call.

2. Self-host fonts in production

CDN-hosted fonts (Google Fonts, Adobe Fonts) work, but they’re:

  • A network hop that slows every render
  • A dependency on a service that might be unavailable at 3am
  • A privacy concern (Google Fonts sends user IPs)

For production, embed fonts as base64 in your CSS or host them on your own domain:

@font-face {
  font-family: 'Inter';
  src: url('https://yourapi.com/fonts/inter.woff2') format('woff2');
  font-weight: 400;
  font-display: block;
}

font-display: block tells the browser to wait for the font — important for PDF renderers where fallback-swap is visible and you can’t go back and re-layout.

3. Tabular numerals for money columns

.money, td.amount {
  font-variant-numeric: tabular-nums;
}

This forces all digits to the same width. Invoice columns look unprofessional without it — decimals don’t line up. One line of CSS, permanent fix.

Paper-physical units vs CSS pixels

CSS has two kinds of length unit:

Absolute (physical):

  • mm, cm, in — metric and imperial
  • pt — 1/72 inch; typographic
  • pc — 12 pt; typographic (rarely used)
  • q — 1/40 cm (almost never used)
  • px — 1/96 inch in print context (yes, really)

Relative:

  • em, rem, %, ch, ex, vw, vh, …

In a PDF render context:

  • Use mm/cm/in/pt for page layout: @page margins, column widths, vertical rhythm.
  • Use em/rem for typography (font-size: 1.1em) and proportional spacing.
  • Use % and vh/vw sparingly — they resolve against the printable area, which is correct but easy to misjudge.
  • Avoid px in @page — it works but reads badly.

Conversions worth memorising:

  • 1 in = 25.4 mm = 72 pt = 96 px
  • 1 mm ≈ 2.83 pt ≈ 3.78 px
  • A4 = 210 × 297 mm = 595 × 842 pt = 794 × 1123 px

Flex and grid in paged layout

Modern CSS layout (flex, grid) works in Chromium’s PDF output, with caveats:

Flex

  • Works fine for small, contained layouts (a card, a header row).
  • break-inside: avoid works but has edge cases with flex-wrap: wrap.
  • Very wide flex containers can overflow the page margin silently — Chromium won’t insert a break.

Grid

  • Works for static grids (fixed-column layouts).
  • Grid row tracks won’t break across pages automatically. A grid row that doesn’t fit will either be pushed to the next page whole or overflow, depending on your break-inside.
  • Complex grid layouts (auto-placement with lots of grid-auto-rows: auto) are the single most common source of “why did my PDF look different from my browser?” bugs.

Rule of thumb: use flex for small contained layouts, use grid for static structure, and fall back to plain blocks (display: block with margins) for anything that needs to paginate cleanly.

Pragmatic advice For long-running content that needs to break nicely across pages — the body of an invoice, a report's paragraphs, a table — use plain block layout. It paginates predictably. Save flex/grid for header/footer strips, summary cards, anything that stays on one page.

Tables: the PDF engineer’s friend

Tables paginate better than any other layout in Chromium PDF output. There are conventions worth knowing:

Repeat headers on each page

thead { display: table-header-group; }
tfoot { display: table-footer-group; }

display: table-header-group / table-footer-group makes Chromium repeat those sections on every page the table spans. Without it, your invoice’s column headers appear only on page 1 and page 2 shows nameless columns.

This is the default behaviour of <thead> and <tfoot> in most browsers when paginated, but declaring it explicitly avoids surprises.

Keep rows together

tr { break-inside: avoid; }

Already covered. Single most useful page-break rule in PDF work.

Zebra-striping, with backgrounds preserved

tbody tr:nth-child(even) {
  background: #F8F8FB;
  print-color-adjust: exact;
}

Don’t forget the print-color-adjust — backgrounds drop without it.

SVG in PDFs

SVGs render as vector PDFs — crisp at any zoom, tiny file size. Use them for diagrams, icons, logos, and charts.

Watch out for:

  • External <image> references inside SVG — Chromium fetches them but may not wait for the fetch if you haven’t set wait_for_network_idle.
  • CSS inside <style> tags in SVG — works, but scoping can surprise you. Inline attributes are safer for one-off graphics.
  • Fonts referenced in SVG <text> — must be loaded in the document’s CSS; Chromium doesn’t fetch SVG-scoped @font-face declarations reliably.

For most invoice/report work, inline SVG for logos and diagrams is the right call. Raster images (PNG, JPG) are fine too but add file size.

Shadow DOM and custom elements

If you’re using web components, Chromium’s PDF output respects shadow DOM styles. The only caveat is that @page rules inside a shadow root are not applied — @page is a top-level construct.

Keep @page in your main document CSS or in a <link rel="stylesheet"> at document level. Everything else works as you’d expect.

Tailwind (and other utility CSS) in PDFs

Tailwind works fine out of the box. A few tweaks worth making for PDF output:

Disable preflight’s margin reset where it interferes

Tailwind’s preflight resets p, h1-h6, ul, ol margins to 0. For prose documents you want those margins back:

/* In your PDF-specific CSS, after Tailwind */
.prose p { margin-bottom: 1rem; }
.prose h1 { margin-top: 2rem; margin-bottom: 1rem; }

Or use @tailwindcss/typography — it’s designed for exactly this.

Tailwind has print: variants for every utility:

<nav class="p-4 print:hidden">...</nav>
<p class="text-gray-500 print:text-black">...</p>
<div class="shadow-lg print:shadow-none">...</div>

These expand to @media print { ... } rules. Works the same way in Chromium’s PDF path.

Tailwind’s default colours render fine in Chromium’s PDF, assuming print-color-adjust: exact is set. Without it, Tailwind’s bg-gray-100 header rows drop to white on the PDF.

/* Add to your globals for PDF work */
* { print-color-adjust: exact; -webkit-print-color-adjust: exact; }

A complete example template

Putting it all together — here’s a compact, production-quality template for a one-page document (invoice/receipt/certificate/etc.) with everything discussed above:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <style>
      /* === PAGE === */
      @page {
        size: A4;
        margin: 20mm 15mm 22mm 15mm;
      }

      /* === RESET (minimal) === */
      * { box-sizing: border-box; margin: 0; padding: 0; }

      /* === PRINT COLOURS === */
      * {
        print-color-adjust: exact;
        -webkit-print-color-adjust: exact;
      }

      /* === TYPE === */
      body {
        font-family: 'Inter', system-ui, sans-serif;
        font-size: 11pt;
        line-height: 1.5;
        color: #0B0B0F;
      }
      h1 { font-size: 22pt; font-weight: 700; letter-spacing: -0.02em; }
      h2 { font-size: 14pt; font-weight: 600; margin: 16pt 0 8pt; }
      .money { font-variant-numeric: tabular-nums; }

      /* === TABLE === */
      table {
        width: 100%;
        border-collapse: collapse;
        margin: 12pt 0;
      }
      thead { display: table-header-group; }
      tfoot { display: table-footer-group; }
      th, td {
        padding: 6pt 8pt;
        text-align: left;
      }
      th {
        background: #F4F4F6;
        font-weight: 600;
        border-bottom: 1pt solid #C8C8D0;
      }
      td { border-bottom: 0.5pt solid #E4E4EC; }
      td.num { text-align: right; font-variant-numeric: tabular-nums; }
      tr { break-inside: avoid; }

      /* === PAGINATION === */
      .page-break { break-before: page; }
      .cover { break-after: page; }
      p { orphans: 3; widows: 3; }

      /* === RUNNING FOOTER === */
      .running-footer {
        position: fixed;
        bottom: 6mm;
        left: 15mm;
        right: 15mm;
        font-size: 9pt;
        color: #8080A0;
        text-align: center;
        border-top: 0.5pt solid #C8C8D0;
        padding-top: 4pt;
      }
    </style>
  </head>
  <body>
    <div class="running-footer">
      Acme Software Inc. · EIN 87-1234567
    </div>
    <!-- body content -->
  </body>
</html>

This covers 95% of real invoice/report/certificate work. Copy, adapt, ship.

Render this template with 21pdf

Free tier: 20 PDFs / month. POST the HTML to /v1/convert, poll, download.

Get API key → Read docs

Closing

CSS for print used to be a specialist skill because the tools were wkhtmltopdf (bad) or Prince (expensive). Chromium’s PDF output has quietly made print CSS a standard web-development skill — the same properties you use on a live web page paginate reasonably well to PDF with just a few overrides.

The four rules that cover the common case:

  1. @page { size: A4; margin: 20mm; }
  2. break-inside: avoid on tables, cards, figures
  3. * { print-color-adjust: exact; } if you want backgrounds
  4. wait_for_network_idle: true in the API call if you use web fonts

Everything else is polish. Come back to this page when you need a specific property.

— 21pdf Engineering

Frequently asked questions

Does Chromium support CSS @page rules?

Yes, for the core properties — size, margin, orphans, widows, and the page-break/break-* family. It does NOT currently support @page margin boxes (@top-center, @bottom-left-corner, etc.) for dynamic header/footer HTML; those still require the API's header/footer template option. Prince XML supports the full Paged Media Level 3 spec.

What's the difference between page-break-* and break-*?

The break-* family (break-before, break-after, break-inside) is the modern spec and the one you should write. page-break-* is the legacy CSS 2.1 version. Modern browsers accept both, but break-* supports values like column, region, etc. in addition to page. For PDF output with Chromium, break-inside: avoid is the most useful one.

How do I add page numbers with CSS?

Real CSS Paged Media uses counter(page) inside an @page margin box (e.g. @page { @bottom-right { content: counter(page); } }). Chromium doesn't render margin-box content in its default PDF path — you need the vendor's header/footer template feature instead (or use Prince). CSS counters work fine inside the body itself.

Why do my CSS units behave weirdly in PDFs?

Chromium's default PDF viewport is 8.5×11 inch at 96 CSS pixels per inch — so 1 inch = 96px, 1 cm ≈ 37.8px, 1 mm ≈ 3.78px. Use mm/cm/pt for anything layout-critical; px works for small elements but is viewport-dependent. 1pt = 1/72 inch.

Can I have different page sizes within one document?

Yes — use @page named pages: `@page:first { size: A3; }`, then reference with `page: first`. Chromium supports named pages via the page property. Use sparingly; mixed page sizes confuse printers and PDF readers.

Why is my page breaking mid-row in a table?

By default, browsers allow content to split anywhere a line-break could occur. Table rows are normal flow content. Fix: `tr { break-inside: avoid; }`. This tells the paginator to move the whole row to the next page if it can't fit.

How do I prevent orphans and widows in long text?

Use the CSS orphans and widows properties (default 2 in print context). These are honoured by Chromium's PDF output. `p { orphans: 3; widows: 3; }` means at least 3 lines of a paragraph will stay together at the start/end of a page.

Do I need @media print or will my CSS work by default?

By default Chromium applies your screen CSS to the PDF — so your Tailwind output works without changes. Use @media print only to override things that should look different in print (hiding nav, removing box-shadows, forcing black-on-white text).