ScreenshotNeo

BlogHTML to image & PDF

How to Prevent Page Breaks Inside Header Tables with Rowspans

Keep complex rowspan table headers together in printed HTML and PDFs with semantic markup, modern break rules, renderer checks, and practical fallbacks.

By the ScreenshotNeo team30 September 20269 min read

How to Prevent Page Breaks Inside Header Tables with Rowspans

When a multi-row table header uses rowspan, preventing it from splitting across printed pages or PDF pages requires two separate pieces: valid table structure and print-specific fragmentation rules. Put every header row in a semantic <thead>, keep body rows in <tbody>, apply break-inside: avoid-page to the header group or its rows, and retain page-break-inside: avoid as a compatibility alias. Then inspect the PDF produced by the exact browser or HTML-to-PDF engine you use.

These rules guide pagination; they do not guarantee identical behavior for every renderer, table height, font, margin, or rowspan arrangement. Header repetition and keeping a header indivisible are related but different outcomes.

1. The baseline HTML structure

Start with standards-compliant table markup. All related header rows belong inside one <thead>. Data rows belong inside <tbody>. A cell with rowspan occupies the intended number of header rows, while colspan describes the columns covered by a group heading.

<table class='report'>
  <caption>Quarterly revenue by region and product</caption>
  <thead>
    <tr>
      <th rowspan='2' scope='col'>Region</th>
      <th colspan='2' scope='colgroup'>Software</th>
      <th colspan='2' scope='colgroup'>Services</th>
    </tr>
    <tr>
      <th scope='col'>Licenses</th>
      <th scope='col'>Support</th>
      <th scope='col'>Consulting</th>
      <th scope='col'>Training</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope='row'>North America</th>
      <td>$120,000</td><td>$32,000</td>
      <td>$58,000</td><td>$14,000</td>
    </tr>
    <tr>
      <th scope='row'>Europe</th>
      <td>$91,000</td><td>$27,000</td>
      <td>$44,000</td><td>$12,000</td>
    </tr>
  </tbody>
</table>

Do not insert arbitrary wrapper elements between <table> and its table sections. Malformed structure can cause the browser to repair the DOM and change how pagination works. Validate that every row has the expected number of effective columns after accounting for spans.

2. Print CSS that keeps the header together

Use the modern break-inside property in a print media query so screen layout is unaffected. The avoid-page value specifically targets page breaks. The older page-break-inside property is a legacy alias retained for compatibility; MDN maps its avoid value to the modern behavior.

The capture pipeline lays out the semantic table, applies print rules, and repeats the header on continuation pages.
The capture pipeline lays out the semantic table, applies print rules, and repeats the header on continuation pages.
@media print {
  thead {
    display: table-header-group;
    break-inside: avoid-page;
    page-break-inside: avoid;
  }

  thead tr {
    break-inside: avoid-page;
    page-break-inside: avoid;
  }

  /* Keep borders and backgrounds visible in printed output. */
  th, td {
    print-color-adjust: exact;
    -webkit-print-color-adjust: exact;
  }
}

.report {
  width: 100%;
  border-collapse: collapse;
}

.report th,
.report td {
  border: 1px solid #777;
  padding: 5pt 7pt;
  vertical-align: top;
}

display: table-header-group enables the usual repeated-header behavior when a table continues onto later pages. Repetition is not the same as a guarantee that a complicated header can never fragment. The CSS Table Module Level 3 specification makes repetition conditional: the page must be the table’s fragmentainer, the header must have break avoidance, the header and footer must fit within the stated height limit, and repetition must not display a row twice on the same page. See the CSS Table Module Level 3 specification.

MDN describes break-inside as controlling how page, column, or region breaks behave inside a generated box. The legacy property’s status and aliases are documented on page-break-inside.

3. A complete Puppeteer PDF example

The following Node.js program writes a PDF with a repeated, protected header. It uses an inline HTML document so it can run without a web server.

import puppeteer from 'puppeteer';
import fs from 'node:fs/promises';

const rows = Array.from({ length: 80 }, (_, i) => `
  <tr>
    <th scope='row'>Region ${i + 1}</th>
    <td>${(120000 - i * 317).toLocaleString()}</td>
    <td>${(32000 + i * 91).toLocaleString()}</td>
    <td>${(58000 + i * 43).toLocaleString()}</td>
    <td>${(14000 + i * 27).toLocaleString()}</td>
  </tr>`).join('');

const html = `<!doctype html>
<html>
<head>
<meta charset='utf-8'>
<style>
  @page { size: A4 landscape; margin: 14mm; }
  body { font-family: Arial, sans-serif; font-size: 9pt; }
  table { width: 100%; border-collapse: collapse; }
  thead { display: table-header-group; break-inside: avoid-page; page-break-inside: avoid; }
  thead tr { break-inside: avoid-page; page-break-inside: avoid; }
  th, td { border: 0.3mm solid #777; padding: 4pt 6pt; }
  th { background: #e9eef5; }
  tr { break-inside: avoid; page-break-inside: avoid; }
</style>
</head>
<body>
<h1>Quarterly revenue</h1>
<table>
  <thead>
    <tr>
      <th rowspan='2'>Region</th>
      <th colspan='2'>Software</th>
      <th colspan='2'>Services</th>
    </tr>
    <tr>
      <th>Licenses</th><th>Support</th>
      <th>Consulting</th><th>Training</th>
    </tr>
  </thead>
  <tbody>${rows}</tbody>
</table>
</body>
</html>`;

const browser = await puppeteer.launch({ headless: true });
try {
  const page = await browser.newPage();
  await page.setContent(html, { waitUntil: 'networkidle0' });
  await page.emulateMediaType('print');
  await page.pdf({
    path: 'report.pdf',
    format: 'A4',
    landscape: true,
    printBackground: true,
    preferCSSPageSize: true
  });
} finally {
  await browser.close();
}

await fs.access('report.pdf');
console.log('Wrote report.pdf');

For Puppeteer installation and current API details, use the project’s documentation and pin the browser version used in production. A renderer update can change pagination, font metrics, or table fragmentation.

4. Why a rowspan header can still split

Several independent constraints affect the result:

  • The header is too tall. The table specification’s repetition rule limits the header and footer to up to one quarter of the page height under its stated conditions. A header with many stacked rows, large padding, or wrapped labels can exceed that limit.
  • Avoidance is a request, not an absolute command. CSS 2.2 explains that an avoid rule can be relaxed when the layout would otherwise lack enough breakpoints to fit the content. Read the paged media section of CSS 2.2.
  • The generated box is not the one you styled. A framework rule may change table section display, or an overflow container may create a different fragmentation context.
  • The renderer has implementation limits. A reported Puppeteer/Chrome case with a dynamic <thead> and rowspan did not respond to applying page-break-inside: avoid to <thead>, <tr>, and <th>. Treat that report as a closely matching example, not a universal browser rule; see the Stack Overflow case.
  • Font and page settings change geometry. A different font, page size, margin, zoom, or device scale can move a wrapped heading onto another line and make the header too tall.

5. A diagnostic checklist

  1. Open the final DOM and confirm that all header rows are descendants of one <thead>.
  2. Check every rowspan and colspan against the intended column count.
  3. In print emulation, inspect computed values for break-inside, page-break-inside, and display.
  4. Look for rules that set thead or tr to display: block, flex, or grid.
  5. Temporarily remove excess padding, long labels, and large fonts to see whether header height is the trigger.
  6. Generate with fixed page size, margins, fonts, and browser version.
  7. Inspect a PDF page where the break occurs. Determine whether the header split, the entire table moved, or the header repeated incorrectly.
  8. After every CSS or renderer change, regenerate the PDF. Do not infer correctness from the screen view alone.

6. Fallbacks when CSS is not enough

Simplify the visual header

Reduce stacked rows, shorten labels, move explanatory text into a caption, or replace a deeply nested rowspan arrangement with a flatter header. This is often more portable than adding more break rules.

Start the table on a fresh page

@media print {
  .report-wrapper { break-before: page; page-break-before: always; }
}

This avoids starting a large header at the bottom of a page, but it wastes available space and should be used only when that trade-off is acceptable.

Split one logical table into sections

If the report has natural groups, render separate tables with short headers. Each table can begin where there is room, and each header is less likely to exceed the page-height condition.

Use engine-specific pagination code only as a last resort

JavaScript that measures page positions and inserts breaks can work for one browser and one set of fonts, but it must be maintained as an engine-specific implementation. The research available here does not establish such a script as a general fix.

7. Capturing the resulting PDF or page

If you are generating a screenshot or PDF from a page, make the pagination CSS part of the page itself and wait until fonts and data are loaded before capture. For local Puppeteer, wait for the network and any application-specific selector:

await page.goto(url, { waitUntil: 'networkidle0' });
await page.waitForSelector('table.report thead');
await page.emulateMediaType('print');
await page.pdf({ path: 'output.pdf', printBackground: true, preferCSSPageSize: true });

For long reports, avoid an unbounded global timeout. Set explicit navigation, font-loading, and data-loading limits, and record the browser version and CSS inputs with each generated artifact.

8. Or skip the browser setup

ScreenshotNeo provides a website screenshot and PDF API when you do not want to maintain browser setup. It can wait for a selector, delay, or network idle; capture full pages with lazy images loaded; apply custom CSS and JavaScript; select an element; choose PDF paper size, margins, landscape mode, and page ranges; and set headers, cookies, user agents, timezones, and geolocation. Use the print CSS above on your page, then make one request.

A clean capture removes obstructive overlays before rendering the page.
A clean capture removes obstructive overlays before rendering the page.

See the ScreenshotNeo documentation for the current parameter list. cURL:

curl -G 'https://api.screenshotneo.com/v1/shot' -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

Python:

import requests
r = requests.get('https://api.screenshotneo.com/v1/shot', params={'access_key': 'YOUR_API_KEY', 'url': 'https://stripe.com'}, timeout=90)
open('shot.webp', 'wb').write(r.content)

Node.js:

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

Cookie banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers identify the page verdict and whether it was billed. An MCP server lets Claude, Cursor, and other MCP clients call take_screenshot, get_page_info, and capture_pdf. The Free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.

9. Performance, reliability, and cost considerations

  • Keep headers compact. Shorter labels and sensible padding reduce layout work and make the one-quarter page-height condition easier to satisfy.
  • Use deterministic inputs. Embed or pin fonts, use a fixed page size, and wait for data and fonts before capture.
  • Cache stable assets. Repeated external fonts, images, and scripts add latency and can cause different line wrapping between runs.
  • Separate failures. Log navigation errors, missing selectors, PDF generation errors, and post-capture validation as different failure classes.
  • Measure output, not just requests. Count generated pages, file size, and whether the header appears on every continuation page.
  • Control third-party content. Ads, trackers, and dynamic widgets can shift table geometry. Block or hide them where your capture pipeline permits.
  • Choose a suitable capture mode. A full-page image is useful for visual review; PDF output is appropriate when selectable text and print pagination matter.

10. FAQ

Does page-break-inside: avoid guarantee an unbroken rowspan header?

No. It is a legacy alias for break avoidance, and a renderer may relax the constraint to fit content or because of implementation limits.

Should I apply the rule to <th> cells?

Style the semantic <thead> and its rows first. Cell-level rules can be useful in a specific engine, but they cannot repair malformed structure or an over-tall header.

Why does the header repeat but still look wrong?

Repeating a header group on later pages does not prove that the original multi-row group stayed together. Check the first page and every continuation page separately.

Can I solve this with a forced break before every table?

Sometimes, but it consumes space and may create many nearly empty pages. Use it only when predictable starts matter more than compact pagination.

Does changing from rowspan to colspan fix the issue?

It can reduce structural complexity, but the correct choice depends on the data model. Simplify only when the visual and accessibility meaning remains clear.

Which renderer should I trust?

Use the renderer that creates your production PDFs and validate there. Browser print preview, Puppeteer, Playwright, and other HTML-to-PDF engines can paginate the same markup differently.