A webpage can keep growing downward. Run out of room? Keep scrolling. A US Letter PDF gets exactly 8.5 by 11 inches at a time. Whenever content reaches the bottom of a page, Chromium has three choices: split it, move it or let it spill somewhere it shouldn't.
That's how perfectly good HTML turns into a PDF with a nearly empty page, clipped content, missing backgrounds or a heading stranded by itself at the bottom. Most of the work happens before the PDF is generated, in the HTML and print styles that control how content moves from one page to the next.
We'll build the print stylesheet first, then use those same rules from Chrome, Puppeteer, Playwright and a hosted API. The CSS will set the paper size and handle page breaks without changing the normal screen layout.
Note
This tutorial targets Chromium, the engine behind Chrome, Puppeteer, Playwright and HTML/CSS to Image. Other HTML-to-PDF engines, including wkhtmltopdf, WeasyPrint and Prince, have their own paged-media behavior.
The examples use US Letter paper, but the same techniques work for A4, Legal and custom page dimensions.
Before You Start
There isn't much setup. You can work through the CSS in any modern browser. The automation examples need a recent version of Node.js or .NET and your choice of Puppeteer, PuppeteerSharp or Playwright. On a fresh machine, make sure the library's compatible Chromium build is installed too. The Playwright and PuppeteerSharp docs cover that setup.
Start with semantic HTML if you can. Real headings, paragraphs, tables, lists and figures give the browser logical places to break. A bunch of absolutely positioned elements does not.
You also need ugly test data: long titles, multi-line addresses, tables that cross a page boundary and a document that runs just a few lines onto a new page. Just because your PDF looks alright when your invoice has 4 items doesn't mean it'll flow nicely with 40.
How HTML Becomes a Paginated PDF
A normal browser window uses the screen media type. Chrome's print preview and the PDF methods in Puppeteer and Playwright use print. That switch is what makes @media print useful: you can simplify a page for paper without touching the layout people use on screen. The MDN printing guide covers the browser-facing pieces.
The @page rule describes the paper: its size and printable margins. The browser pours the document into those page boxes, then uses rules such as break-before, break-after and break-inside when it reaches an edge.
Decide who owns the paper settings:
- CSS can define them with
@page. - A renderer such as Puppeteer or Playwright can supply a format, width, height and margins.
Pick one, or make sure the two match. Conflicting sizes and margins are a common source of mysterious scaling and off-by-one page counts. In the examples below, CSS owns the geometry for manual printing and browser automation. The API example puts the same values in renderer options.
Step 1: Add Print-Specific Styles
Start by removing what does not belong in the document. Hide the controls, then strip out the widths, margins and shadows that only made sense inside the application shell.
@media print {
.site-header, .site-footer, .navigation, .print-button, .screen-only {
display: none !important;
}
body {
background: #fff;
}
.document {
width: auto;
max-width: none;
margin: 0;
padding: 0;
border: 0;
box-shadow: none;
}
}
Keep print rules after the main styles so the cascade works in your favor. For a larger application, a dedicated print-only file can be easier to reason about:
<link rel="stylesheet" href="/styles/site.css">
<link rel="stylesheet" href="/styles/print.css" media="print">
I prefer an @media print block when the printable component and its regular styles live together. A dedicated file makes more sense once print rules cut across a lot of the application.
One easy mistake is hiding anything clickable. An order number may be a link on screen, but the number is still useful in the PDF. Hide the interaction, not the information.
Step 2: Set the Page Size and Margins
CSS rules that begin with @ are called at-rules. @media print is a media query: it applies ordinary styles only when the document is being printed. @page is a different at-rule. It configures the printed page itself, including its size and margins.
Use it like this:
@page {
size: letter portrait;
margin: 0.75in;
}
One value applies the same three-quarter-inch margin on every side. With four values, the order is top, right, bottom, left.
Common page sizes include:
| Paper | CSS | Dimensions |
|---|---|---|
| US Letter | size: letter |
8.5 in × 11 in |
| A4 | size: A4 |
210 mm × 297 mm |
| US Legal | size: legal |
8.5 in × 14 in |
| A5 | size: A5 |
148 mm × 210 mm |
Named sizes are readable, but explicit dimensions work too:
@page {
size: 8.5in 11in;
margin: 0.75in;
}
CSS accepts absolute units such as mm, cm, in and pt for paper geometry. Puppeteer and Playwright document px, in, cm and mm for PDF option strings, while HTML/CSS to Image's pdf_options also accepts pt. Pixels work, but nobody should have to reverse-engineer whether 816px was meant to be US Letter.
Page margins are not the same as body padding. An @page margin repeats on every generated page. Padding belongs to one HTML element, even when that element happens to be split across several pages.
Double margins usually mean they were defined twice. If your renderer accepts margins, either let @page own them or remove the CSS margin and configure it in the renderer. Don't guess which one wins.
Tip
Need page numbers? Chromium 131 and newer can place content directly in the page margins. This is a better fit for page numbers, document titles and other repeating page furniture than pretending they are table headers.
@page {
size: letter portrait;
margin: 0.75in;
@bottom-center {
content: "Page " counter(page) " of " counter(pages);
color: #667085;
font-size: 9pt;
}
}
counter(page) is the current page and counter(pages) is the total. Leave enough margin for the content, and turn off Chrome's own Headers and footers print setting if it creates a duplicate. Older Chromium builds can use the renderer's header and footer templates instead. Chrome's page-margin-box guide shows the other available positions.
Step 3: Don't Build Fake Pages
It's tempting to make a stack of HTML elements that are each exactly 11in high. It feels controlled, and the first sample often looks great. Then an address wraps onto a second line and every page after it shifts.
Ordinary document flow holds up much better:
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
}
body {
font-family: Inter, Arial, sans-serif;
font-size: 10.5pt;
line-height: 1.5;
color: #182230;
}
img, svg {
display: block;
max-width: 100%;
height: auto;
}
Avoid fixed heights around variable content. Use min-height when a design really needs a minimum area, perhaps on the first page of a certificate, but still test what happens when the content outgrows it.
Scroll containers with automatic overflow handling are another screen pattern that doesn't translate to paper:
.table-wrapper {
max-height: 500px;
overflow: auto;
}
That wrapper is useful in your web app to keep things interactive. On the printed PDF, it can print only the visible piece of the table. Undo it for print:
@media print {
.table-wrapper {
max-height: none;
overflow: visible;
}
}
Step 4: Control Page Breaks
Now add breaks (sparingly). Modern print CSS gives us three related fragmentation properties:
break-beforecontrols a boundary before an element.break-aftercontrols a boundary after an element.break-insidecontrols whether the browser should split an element.
For an actual document boundary, such as a new appendix or invoice section, force a new page with a reusable class:
.new-page {
break-before: page;
page-break-before: always;
}
The second declaration is the old spelling. Current browsers alias the common page-break-* behavior, but keeping both costs almost nothing and helps if the HTML may reach an older engine.
Keep a heading with at least some of the content that follows it:
h2, h3 {
break-after: avoid-page;
page-break-after: avoid;
}
Self-contained pieces can ask to stay together:
.card, .summary, .signature, figure, blockquote {
break-inside: avoid;
page-break-inside: avoid;
}
The word avoid is pretty important. It is "please try and keep this content together" not "never break this up." If a card is taller than the printable area, the browser still has to split or overflow it. Applying break-inside: avoid to the wrapper around an entire report usually makes pagination worse, not better.
Paragraph fragmentation can also be improved with orphans and widows:
p, li {
orphans: 3;
widows: 3;
}
These CSS properties ask for at least three lines on either side of a split paragraph. Browser behavior varies, so think of them as a nice-to-have polish. They won't rescue a layout built around fixed heights and overflowing containers.
Step 5: Make Long Tables Printable
Tables are where print CSS usually earns its keep. A four-row sample behaves; a 60-row invoice finds every weak spot in its alignment, wrapping and page breaks.
Start with real table markup and leave its display behavior alone:
<table>
<thead>
<tr>
<th scope="col">Description</th>
<th scope="col">Quantity</th>
<th scope="col">Amount</th>
</tr>
</thead>
<tbody>
<!-- Variable number of rows -->
</tbody>
</table>
<div class="table-total">
<strong>Total</strong>
<span>$1,248.00</span>
</div>
Then give the browser a little help:
table {
display: table;
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
thead {
display: table-header-group;
break-inside: avoid;
page-break-inside: avoid;
}
tbody {
display: table-row-group;
}
tr {
display: table-row;
break-inside: avoid;
page-break-inside: avoid;
}
th, td {
padding: 2.5mm 2mm;
border-bottom: 0.2mm solid #d7dde5;
text-align: left;
vertical-align: top;
overflow-wrap: anywhere;
}
When Chromium paginates content using the CSS table layout, it can draw the header group again when the table continues onto another page. A real <thead> already has that layout role unless another style overrides it. Test this with a long table. An unusually tall header or a layout that cannot fragment cleanly can prevent the repetition.
This depends on the table elements keeping their table display roles. Responsive styles sometimes turn a table into stacked cards by setting table, thead, tbody or tr to block, grid or flex. That may be useful on a narrow screen, but <thead> is no longer a table header group, so Chromium has nothing to repeat.
An overflow: auto wrapper causes a different problem. It creates a scrolling box on screen. In print, that box may clip the table or prevent the browser from paginating the rows normally. Reset both kinds of screen behavior in the print stylesheet:
@media print {
.table-wrapper {
max-height: none;
overflow: visible;
}
table {
display: table;
}
thead {
display: table-header-group;
}
tbody {
display: table-row-group;
}
tr {
display: table-row;
}
}
These are the default display values for semantic table elements. Writing them explicitly in @media print is useful when application or framework styles override those defaults at smaller viewport sizes.
Can a Non-Table Element Repeat as a Table Header?
Sometimes. The HTML tag itself is not what makes the repetition work. Chromium can repeat non-table elements when CSS gives them a complete table formatting structure:
<div class="report-table">
<div class="report-header">
<div class="report-row">
<div class="report-cell">Description</div>
<div class="report-cell">Amount</div>
</div>
</div>
<div class="report-body">
<div class="report-row">
<div class="report-cell">Annual plan</div>
<div class="report-cell">$240.00</div>
</div>
<!-- More rows -->
</div>
</div>
@media print {
.report-table {
display: table;
width: 100%;
}
.report-header {
display: table-header-group;
break-inside: avoid;
}
.report-body {
display: table-row-group;
}
.report-row {
display: table-row;
}
.report-cell {
display: table-cell;
}
}
The structure has to be complete: table, header group, rows and cells. Applying display: table-header-group to one unrelated block is not enough. The browser may invent anonymous table boxes to fill in missing levels. That can make a simple example appear to work while producing unpredictable pagination later.
This changes layout, not meaning. A <div> remains a <div> to screen readers and other tools that consume the document. Use actual table markup when the content is tabular. Repeated document titles, logos and page numbers belong in the @page margin boxes described above, or in the renderer's header and footer feature on older Chromium builds.
Watch out for <tfoot>: browsers may repeat it on every page too. That's useful for a repeated note and very wrong for an invoice grand total. Put a final-only total after the table and keep that smaller block together:
.table-total {
display: flex;
justify-content: flex-end;
gap: 12mm;
margin-top: 4mm;
break-inside: avoid;
page-break-inside: avoid;
}
Don't put break-inside: avoid on the whole table. It asks the browser to keep every row together and can leave a huge blank area. Apply it to rows. A single row taller than the page is still going to split; CSS can't make it smaller.
For a table that is too wide:
- Shorten or wrap long identifiers.
- Assign sensible column widths.
- Reduce cell padding before shrinking all typography.
- Move secondary fields below the primary cell content.
- Use a landscape page only when the content genuinely needs it.
Shrinking a desktop table until it technically fits is not much of a solution if nobody can read the result.
Step 6: Preserve Colors, Backgrounds and Images
Chrome tries to be conservative about your ink/toner. It may adjust colors and skip background graphics unless the user says otherwise. Mark the parts whose meaning or design depends on their colors:
.document, .status-badge, .chart, thead {
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
CSS alone does not turn on background printing. Set printBackground: true in Puppeteer or Playwright, or pdf_options.print_background in HTML/CSS to Image. print-color-adjust: exact asks Chrome not to simplify those colors for printing. Use both when the exact appearance matters. A person using the print dialog can still override the choice.
A PDF can preserve a blurry image with perfect fidelity. Use SVG for logos and diagrams where possible, and make sure photographs and other raster assets are large enough for their printed size.
Give remote images stable dimensions so a late arrival doesn't move every page break after it. For an <img>, include its real width and height attributes so the browser knows the aspect ratio before the file arrives:
<img class="logo" src="/images/logo.png" width="680" height="180" alt="Example Corp">
.logo {
width: 34mm;
height: auto;
}
.chart {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: contain;
break-inside: avoid;
}
Step 7: Wait for Fonts and Images
Fonts are layout, not decoration. A late font swap changes word widths, line wrapping and every page break after it. Images do the same thing vertically. The initial HTML response arriving does not mean the document is ready.
Puppeteer's waitForFonts option defaults to true. The helper below also waits up to 10 seconds for the <img> elements currently in the document and gives Playwright the same check. It does not know whether a canvas chart, CSS background, iframe or later JavaScript update is finished.
async function waitForCurrentAssets(page) {
await page.evaluate(async () => {
const assetsReady = (async () => {
await document.fonts.ready;
await Promise.all(
Array.from(document.images).map(async (image) => {
if (image.complete) {
await image.decode().catch(() => {});
return;
}
await new Promise((resolve) => {
image.addEventListener("load", resolve, { once: true });
image.addEventListener("error", resolve, { once: true });
});
})
);
})();
await Promise.race([
assetsReady,
new Promise((resolve) => setTimeout(resolve, 10_000)),
]);
});
}
The 10-second deadline keeps one sick image server from holding a PDF worker forever. This helper treats a broken image or the deadline as “done waiting,” so use it for layout stability rather than validation. If a missing logo or chart should fail the job, check that asset explicitly before creating the PDF.
Pages that continuously poll or stream data may never become network-idle, so the automation examples below do not wait for it. Have the application set an element, attribute or JavaScript flag when the report is ready. Keep an overall timeout around the render too.
If you load a real URL, the PDF worker needs access to every asset URL too. With page.setContent(), relative URLs have no useful application base unless you add one, for example with <base href="https://example.com/">, or turn them into absolute URLs first.
Step 8: Test the Print Layout in Chrome
Open Chrome's print preview early, not after the stylesheet feels finished. Pick the same paper size, margins and background setting that production will use.
Don't test with eight tidy rows and call it done. Use documents that are deliberately annoying:
- An almost empty invoice or report
- One document that ends just before a page boundary and another that spills a few lines past it
- A table long enough to cross at least three pages
- The longest real titles, addresses and table-cell values you expect to receive
- Missing optional sections, missing images and an image URL that is intentionally slow or broken
- Every paper size customers can choose, rather than assuming a layout that works on Letter also works on A4
The account statement example is a useful document to pull apart. It fetches a generated 60-day ledger, fills the transaction table with JavaScript and renders multiple US Letter pages. The page includes the HTML, CSS, request settings and finished PDF, so you can change the data or break rules and inspect the result.
Check the first, middle and final pages, not just the thumbnails. The last page exposes forced breaks and fake page heights. A middle page tells you whether repeated table headers and “keep together” rules survive a real document.
When print preview looks right, send the same ugly test document through the production renderer and put the two PDFs side by side. The page count should not be “close.” If it changes, compare the browser version, page dimensions, margins, scale, fonts and background settings before touching the CSS again.
If paper is the real destination, print one. Type that looks fine on a bright display can feel tiny on paper, and a light gray border can disappear completely.
Step 9: Generate the PDF Programmatically
Now the same browser pipeline can run in code.
Generate a PDF with Puppeteer or Playwright
page.pdf() in Puppeteer and page.pdf() in Playwright use print media automatically. Each example loads the same report and waits for a marker set by the application. It then runs the asset helper from Step 7. The CSS @page rule owns the size and margins.
The sample assumes your application adds data-pdf-ready="true" to an element when its data and charts are finished. Replace that selector with whatever “ready” means in your application.
import puppeteer from "puppeteer";
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage();
await page.goto("https://example.com/reports/quarterly", {
waitUntil: "domcontentloaded",
});
await page.waitForSelector("[data-pdf-ready='true']");
await waitForCurrentAssets(page);
await page.pdf({
path: "quarterly-report.pdf",
preferCSSPageSize: true,
printBackground: true,
waitForFonts: true,
});
} finally {
await browser.close();
}
preferCSSPageSize gives @page priority in each library. If the browser code should own the paper geometry instead, remove size and margin from @page and put them in the PDF options:
await page.pdf({
path: "quarterly-report.pdf",
format: "letter",
margin: {
top: "0.75in",
right: "0.75in",
bottom: "0.75in",
left: "0.75in",
},
printBackground: true,
});
Pick whichever model fits the application. Just make the ownership obvious in the code.
Generate a PDF with an API
Puppeteer or Playwright makes sense if browser automation is already part of the application. Otherwise you are signing up to maintain Chrome installations, worker processes, memory limits, timeouts and scaling. An API handles that infrastructure when all you need is the file.
For an HTML/CSS to Image PDF render, move the dimensions and margins out of @page and into pdf_options. Keep the media query, table rules and break behavior. Set media_type to print so those rules are active:
curl -X POST https://hcti.io/v1/image \
-u 'UserID:APIKey' \
-H 'Content-Type: application/json' \
-d '{
"html": "<!doctype html><html><head>...</head><body>...</body></html>",
"format": "pdf",
"media_type": "print",
"pdf_options": {
"page_width": "8.5in",
"page_height": "11in",
"margins": ["0.75in", "0.75in", "0.75in", "0.75in"],
"print_background": true
}
}'
The response URL ends in .pdf. media_type selects the CSS environment, and pdf_options supplies the paper settings. The API still uses Chromium's print pipeline, so you still need the stylesheet. The service runs the browser workers for you.
If JavaScript still needs to fetch data or draw charts, set render_when_ready: true and call ScreenshotReady() after the document is complete. The account statement linked in Step 8 uses that pattern before rendering its multi-page PDF.
Complete Print Stylesheet
Here is the same stylesheet condensed into one block. All normal style rules are inside @media print, so it can live next to the screen styles without changing them. The @page rule only affects paged output. If the renderer should own the geometry, remove size and margin from @page.
@page {
size: letter portrait;
margin: 0.75in;
}
@media print {
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
}
body {
color: #182230;
background: #fff;
font-family: Inter, Arial, sans-serif;
font-size: 10.5pt;
line-height: 1.5;
}
.site-header, .site-footer, .navigation, .print-button, .screen-only {
display: none !important;
}
.document {
width: auto;
max-width: none;
margin: 0;
padding: 0;
border: 0;
box-shadow: none;
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
h1, h2, h3 {
line-height: 1.2;
}
h2, h3 {
break-after: avoid-page;
page-break-after: avoid;
}
p, li {
orphans: 3;
widows: 3;
}
img, svg {
display: block;
max-width: 100%;
height: auto;
}
figure, .card, .summary, .signature {
break-inside: avoid;
page-break-inside: avoid;
}
.new-page {
break-before: page;
page-break-before: always;
}
.table-wrapper {
max-height: none;
overflow: visible;
}
table {
display: table;
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
thead {
display: table-header-group;
break-inside: avoid;
page-break-inside: avoid;
}
tbody {
display: table-row-group;
}
tr {
display: table-row;
break-inside: avoid;
page-break-inside: avoid;
}
th, td {
padding: 2.5mm 2mm;
border-bottom: 0.2mm solid #d7dde5;
text-align: left;
vertical-align: top;
overflow-wrap: anywhere;
}
thead th {
color: #fff;
background: #263b5e;
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
.table-total {
display: flex;
justify-content: flex-end;
gap: 12mm;
margin-top: 4mm;
break-inside: avoid;
page-break-inside: avoid;
}
}
Common HTML-to-PDF Problems
The PDF has an extra blank page
The usual culprits are a forced break on the final element, a fixed-height page wrapper or an element that is a hair wider than the printable area. 100vh, 11in containers and full-page borders are especially good at pushing an otherwise empty fragment onto a new page.
Remove fixed page heights first. If the blank page survives, outline everything and find the box crossing the boundary:
@media print {
* {
outline: 0.2mm solid rgb(255 0 0 / 15%);
}
}
It is ugly and surprisingly effective. Remove it when you are done.
Content is clipped on the right
Look for fixed pixel widths, min-width, non-wrapping text and overflow containers. Desktop table styles are frequent offenders. Reset those constraints for print and make sure box-sizing: border-box is in place.
For long URLs and identifiers, try overflow-wrap: anywhere. If the problem is a wide table, fix the table layout instead of shrinking the entire document.
Background colors are missing
Enable background printing in the renderer. In Puppeteer and Playwright that is printBackground: true. In HTML/CSS to Image it is pdf_options.print_background: true. If Chrome is also muting or adjusting the colors, add print-color-adjust: exact and its WebKit-prefixed form to the relevant elements.
A page-break rule is ignored
Check the basic layout conditions first:
- The rule is active under the
printmedia type. - The target creates a box in normal layout.
- The element is not absolutely or fixed positioned.
- An ancestor is not clipping it with
overflow. - The element is not inside a complicated Flexbox or Grid fragmentation context.
- The content you asked to keep together is not taller than the page.
If the element lives in a complicated Grid or Flexbox layout, switching that section back to block flow for print is often more reliable than piling on more break rules:
@media print {
.dashboard-grid {
display: block;
}
.dashboard-grid > * + * {
margin-top: 8mm;
}
}
Table headers do not repeat
Inspect the computed styles for table, thead, tbody and tr. A CSS framework may have quietly changed one of them. Restore table, table-header-group, table-row-group and table-row, then remove scrolling or clipping from the table's ancestors.
The PDF uses a fallback font
Check the unglamorous details: Is the URL absolute? Can the worker reach it? Does the server allow the cross-origin request? Did you actually provide the weight used by the stylesheet? Then wait for document.fonts.ready before generating the PDF.
A fallback font changes character widths, which can change line wrapping and the final page count.
Chrome and production generate different page counts
Compare the actual inputs: browser version, paper dimensions, margins, scale, font files, media type and background settings. Check again for competing CSS and renderer margins. Dates, timezones and late-loading data can also make two supposedly identical renders different.
Frequently Asked Questions
What does @media print do?
It applies CSS only while the browser is printing, which includes browser-generated PDFs. Use it to hide navigation, remove app chrome and turn a responsive screen layout into something that fits a fixed page.
Which CSS property should I use for page breaks?
Use break-before, break-after and break-inside. Add the corresponding page-break-* declarations if the document might pass through an older rendering engine.
Should margins be set in CSS or the browser renderer?
Either works. Use CSS when people also print from the browser. Use renderer options when API calls need different formats. Do not define margins in both places.
Why does break-inside: avoid sometimes fail?
Usually because the element is taller than the printable area, or because an overflow, positioning, Flexbox or Grid rule has taken it out of a simple fragmentable flow. avoid is a preference, not a guarantee.
How do I repeat a table header on every PDF page?
Use a real <thead> and preserve display: table-header-group in print CSS. Remove scrolling and clipping from the table's ancestors, and undo framework styles that turn table elements into blocks. Test the result with the Chromium version and table data you ship.
How do I add page numbers to a PDF?
In Chromium 131 and newer, use an @page margin box with counter(page) and counter(pages). For older Chromium versions, use the header and footer options provided by the PDF renderer.
Do I need Puppeteer or Playwright to convert HTML to PDF?
No. Chrome's print dialog handles manual exports. Puppeteer and Playwright automate the same sort of browser work. A managed HTML-to-PDF API is another option when you want automation but not the browser processes behind it.
Start with the Print Stylesheet
Fix pagination in the print stylesheet first. Verify it in Chrome with awkward real-world content, then automate the same render.
If the application already has a Puppeteer or Playwright worker, use it. Otherwise, our HTML-to-PDF API can return the PDF URL without adding another browser worker to the stack.
