Get started
Playwright PDF header and footer: the complete fix for displayHeaderFooter

Playwright PDF header and footer: the complete fix for displayHeaderFooter

Playwright PDF headers and footers silently fail. Four documented footguns, the fix for each, and the DOM-restructure trick that handles complex layouts.

14 min read

Playwright's page.pdf({ displayHeaderFooter: true, headerTemplate, footerTemplate }) is the API everyone reaches for, and the API everyone gets stuck on. Four documented Chromium footguns make headers and footers silently disappear: a default font-size of zero, no reserved margin, no CSS inheritance, and CSS counters that do not work. Fix 95% of cases by inlining font-size: 10px on the template root, setting margin: { top: '25mm', bottom: '20mm' }, and using the five special class names (.pageNumber, .totalPages, .title, .date, .url). For anything more complex, restructure the DOM with <thead> and <tfoot>.

Why your Playwright header silently disappears

Chromium does not render headerTemplate inside your page. It spins up a separate, sandboxed iframe at print time, parses the template string as its own HTML document, and prints the result into the top margin. That iframe inherits nothing from your main page: no stylesheets, no CSS variables, no fonts, no images, no JavaScript-generated content. It also starts with browser defaults that are tuned for Chromium's internal print preview, not for application PDFs.

This isolation is by design. The Chrome DevTools Protocol's Page.printToPDF treats the header and footer as a separate sub-document so the print pipeline can apply them uniformly across every printed page. Playwright and Puppeteer both wrap the same CDP call (Playwright docs, Puppeteer docs), so the behavior is identical in both libraries.

The pain shows up in GitHub: issues microsoft/playwright#14441 and microsoft/playwright#29573 both describe templates that render blank, styles that get ignored, and page numbers that refuse to appear. These threads have hundreds of thumbs-up reactions and no merged fix because every report is, in fact, a known Chromium behavior. Here are the four footguns and the fix for each.

Footgun 1: zero font size by default

The single most common failure. Pass a string like <div>Page header</div> as headerTemplate and you get a blank space at the top of every page. The text is there. It renders at 0 pixels.

Chromium's header iframe ships with font-size: 0 as the default. Your page's CSS does not apply, so there is no body { font-size: ...} rule to fall back to. Every text node renders, but at zero pixels it is invisible.

The fix is one line: wrap the template content in a root element with an inline font-size style.

import { chromium } from 'playwright';
 
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setContent('<h1>Hello</h1><p>Body content</p>');
 
const pdf = await page.pdf({
  format: 'A4',
  displayHeaderFooter: true,
  headerTemplate: '<div style="font-size:10px;width:100%;padding:0 10mm">My header</div>',
  footerTemplate: '<div style="font-size:10px;width:100%;text-align:center">Footer</div>',
  margin: { top: '25mm', bottom: '20mm', left: '15mm', right: '15mm' },
});
 
await browser.close();

A few details that matter:

  • width: 100% is needed because the iframe defaults to width: auto, which collapses the element.
  • padding: 0 10mm adds horizontal breathing room. The header iframe has no margin of its own, so without padding text sits flush against the page edge.
  • The inline style must be on the root element of the template string. Styles on a nested child do not cascade to the root because there is no parent rule overriding the zero default.

Footgun 2: no margin reserved for the header

Headers and footers render inside the page margins. If margin.top is too small, the header clips. If it is zero, the header is silently dropped.

Chromium does not auto-resize margins to fit your header. You have to do the math yourself: pick a font size, estimate the header height, then add at least 5mm of breathing room so the header does not bump against the body content.

Header contentRecommended margin.topRecommended margin.bottom
Single line, 10px font15mm15mm
Single line with logo (20mm logo)25mm15mm
Two lines of 10px text20mm15mm
Two lines + page numbers25mm20mm
Multi-row header (logo + tagline + meta)35mm20mm
Full letterhead45mm25mm

A working starting point for a typical document is { top: '25mm', bottom: '20mm', left: '15mm', right: '15mm' }. If your header still clips, increase margin.top by 5mm at a time until it fits. Inspecting the generated PDF in a viewer and measuring the gap between the page top and your first body element is the fastest debug loop.

Footgun 3: no CSS, no external assets

The header iframe is a fresh document. It cannot see your application's stylesheets, your <style> blocks, your CSS custom properties, or any fonts you loaded on the main page. It can usually load external images, but the iframe has a short timeout and no referer, so external images fail intermittently in real-world deployments.

Two rules cover this:

  1. Inline every style as a style="..." attribute on the element that needs it.
  2. Inline every image as a base64 data:image/png;base64,... URL.

If you need a web font, the header template can include its own <link rel="stylesheet"> tag pointing to Google Fonts or another stylesheet host. The font will load in the iframe at print time, separate from your main page's fonts.

Here is a complete pattern with a base64 logo, a web font, and inline styles:

const logoBase64 = 'iVBORw0KGgoAAAANSUhEUgAA...'; // truncated
 
const headerTemplate = `
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@500&display=swap">
  <div style="
    font-family: Inter, sans-serif;
    font-size: 10px;
    width: 100%;
    padding: 0 15mm;
    display: flex;
    justify-content: space-between;
    align-items: center;
    color: #333;
  ">
    <img src="data:image/png;base64,${logoBase64}" style="height: 18px" />
    <span>Confidential</span>
  </div>
`;
 
await page.pdf({
  format: 'A4',
  displayHeaderFooter: true,
  headerTemplate,
  footerTemplate: '<div></div>',
  margin: { top: '30mm', bottom: '20mm', left: '15mm', right: '15mm' },
});

The Playwright and Puppeteer code is the same byte-for-byte because both wrap the same CDP call.

Footgun 4: page numbers via special classes

Developers reach for counter(page) and counter(pages) first because that is how CSS paged media defines page numbering (CSS 2.1 paged media). Inside headerTemplate and footerTemplate, those counters do not work. Chromium intercepts the templates before CSS counters resolve.

Instead, Chromium substitutes five special class names at print time. Place an element with one of these classes and its textContent is replaced with the live value:

The five special classes recognized in headerTemplate and footerTemplate:

  • .pageNumber: current page index (1-based)
  • .totalPages: total number of pages
  • .title: the document title (from <title> of the main page)
  • .date: the print date in the browser's locale
  • .url: the page URL

These names are case-sensitive. Spelling them as .pagenumber or .PageNumber produces empty text.

A working "Page X of Y" footer:

const footerTemplate = `
  <div style="font-size:10px;width:100%;text-align:center;color:#666;padding:0 15mm">
    Page <span class="pageNumber"></span> of <span class="totalPages"></span>
  </div>
`;

The <span> elements must be empty when you write the template. Chromium fills them in at print time. If you write <span class="pageNumber">1</span> the substitution still happens, but only because Chromium replaces the inner text; the literal "1" is overwritten.

The escape hatch: DOM restructure with thead and tfoot

If your header is more than a logo and a page number, the iframe model becomes painful fast. No flexbox debugging in DevTools, no shared CSS, no JavaScript, no access to the same data your page renders from. At that point, stop fighting displayHeaderFooter and let Chromium repeat the header from your main DOM instead.

The CSS 2.1 paged media spec (w3.org/TR/CSS21/page.html) defines that <thead> and <tfoot> elements inside a <table> repeat on every printed page when the table spans multiple pages. The MDN documentation for display: table-header-group and table-footer-group confirms the same behavior for non-table elements that opt into the table layout model.

The pattern is to wrap your entire document body in a single table, with <thead> for the header, <tfoot> for the footer, and <tbody> for the content:

<html>
<head>
  <style>
    body { margin: 0; font-family: Inter, sans-serif; }
    table { width: 100%; border-collapse: collapse; }
    thead { display: table-header-group; }
    tfoot { display: table-footer-group; }
    .header { padding: 10mm 15mm; border-bottom: 1px solid #eee; }
    .footer { padding: 5mm 15mm; font-size: 10px; color: #666; text-align: center; }
    .content { padding: 10mm 15mm; }
  </style>
</head>
<body>
  <table>
    <thead>
      <tr><td>
        <div class="header">
          <img src="..." style="height:24px" />
          <span>Confidential · Acme Corp</span>
        </div>
      </td></tr>
    </thead>
    <tfoot>
      <tr><td>
        <div class="footer">www.example.com</div>
      </td></tr>
    </tfoot>
    <tbody>
      <tr><td>
        <div class="content">
          <!-- your real document body here -->
        </div>
      </td></tr>
    </tbody>
  </table>
</body>
</html>

Then call page.pdf() with displayHeaderFooter: false (or omit it). Chromium repeats the <thead> and <tfoot> rows on every printed page using the main page's CSS, fonts, and images.

Three benefits compared to headerTemplate:

  1. The header sees your stylesheets, web fonts, and data. No base64 image dance, no inline-everything ritual.
  2. You can debug the header in Chrome DevTools as a normal DOM element. Print emulation (Rendering panel → Emulate CSS media type → print) shows exactly what the PDF will produce.
  3. Complex layouts like flexbox columns, conditional content, or JavaScript-generated meta work without a second template engine.

The trade-off is that you do not get page-number substitution via .pageNumber and .totalPages. If you need page numbers, either combine the two approaches (use <thead> for the rich header, and a minimal footerTemplate with .pageNumber for the number) or accept that page numbers require the iframe path.

If you find yourself fighting headerTemplate for more than a couple of hours, switch to the <thead> / <tfoot> approach. It is the same trick that PDF4.dev uses internally to power its <pdf4-header> and <pdf4-footer> components, and it scales cleanly to letterheads, multi-line metadata, conditional sections, and brand-heavy layouts. See PDF4.dev templates docs for the full pattern.

Real-world example: multi-page invoice header

A complete working script: a Playwright script that loads HTML containing a <thead>-wrapped invoice header and a page-number footer, calls page.pdf(), and produces a clean five-page invoice with the header repeated on every page.

import { chromium } from 'playwright';
 
const html = `
<html>
<head>
  <meta charset="utf-8">
  <title>Invoice INV-2026-042</title>
  <style>
    body { margin: 0; font-family: -apple-system, sans-serif; color: #111827; }
    table.layout { width: 100%; border-collapse: collapse; }
    thead.invoice-header { display: table-header-group; }
    .header-inner {
      padding: 12mm 15mm 8mm;
      border-bottom: 2px solid #111827;
      display: flex;
      justify-content: space-between;
      align-items: flex-end;
    }
    .header-inner h1 { font-size: 16pt; margin: 0; }
    .header-inner .meta { font-size: 9pt; color: #6b7280; }
    .content { padding: 8mm 15mm; }
    .line { padding: 4mm 0; border-bottom: 1px solid #e5e7eb; }
  </style>
</head>
<body>
  <table class="layout">
    <thead class="invoice-header">
      <tr><td>
        <div class="header-inner">
          <div>
            <h1>Acme Corp</h1>
            <div class="meta">Invoice INV-2026-042</div>
          </div>
          <div class="meta">2026-06-06</div>
        </div>
      </td></tr>
    </thead>
    <tbody>
      <tr><td>
        <div class="content">
          ${Array.from({ length: 60 }, (_, i) =>
            `<div class="line">Line item ${i + 1}: Professional services: $250.00</div>`
          ).join('')}
        </div>
      </td></tr>
    </tbody>
  </table>
</body>
</html>
`;
 
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'load' });
 
const footerTemplate = `
  <div style="font-size:9px;width:100%;text-align:center;color:#6b7280;padding:0 15mm">
    Page <span class="pageNumber"></span> of <span class="totalPages"></span>
  </div>
`;
 
const pdf = await page.pdf({
  format: 'A4',
  displayHeaderFooter: true,
  headerTemplate: '<div></div>',
  footerTemplate,
  margin: { top: '0mm', bottom: '15mm', left: '0mm', right: '0mm' },
  printBackground: true,
});
 
await browser.close();

What is happening here:

  • The invoice header is rendered through <thead> with the main page's CSS, so flexbox, custom fonts, and the bottom border all work.
  • margin.top is zero because the <thead> provides its own padding. The header content starts at the page top.
  • margin.bottom is reserved for the footer iframe, which only contains the page number using .pageNumber and .totalPages.
  • An empty headerTemplate (<div></div>) is still required when displayHeaderFooter: true. Omitting it makes Chromium fall back to its default print header (URL and date).

The output is five pages with the Acme Corp header repeating at the top of every page and "Page 1 of 5", "Page 2 of 5", etc. at the bottom.

When to stop fighting and use a managed API

The four footguns above are well-documented and the workarounds are stable. None of this is hard once you know what to look for. But there is a real engineering cost: every template needs to be tested at three or more pages, every change to the header needs a base64 image round-trip if a logo changes, and every new layout starts with the same "why is my text invisible" debug session.

If you are building a small number of PDFs (under 100 a day) for an internal tool, the Playwright path is fine. If you are generating thousands of PDFs across many template variants, or shipping a feature where customers can design their own headers and footers, the iframe model becomes a tax.

PDF4.dev's <pdf4-header> and <pdf4-footer> components use the <thead> / <tfoot> DOM-restructure pattern internally. You write your header as a normal HTML fragment with normal CSS, reference it as <pdf4-header component-id="comp_xxx"> in your template, and the API takes care of the table wrapping and Chromium configuration. Page numbers are handled via Handlebars helpers, not class-name substitution. See /docs/templates for the full pattern.

Frequently asked questions

These short answers also appear in the FAQ JSON-LD that ships with this article. They are written to be lifted verbatim by AI assistants and SERP rich results.

Why is Playwright's PDF header blank? Chromium renders the header in an isolated iframe with font-size: 0 by default. Wrap the template content in a root element with an inline font-size: 10px style.

How do I set the font size in headerTemplate? Inline the style: <div style="font-size:10px;width:100%">...</div>. External CSS and CSS variables do not reach the header iframe.

How do I show page numbers in a Playwright PDF? Use <span class="pageNumber"></span> and <span class="totalPages"></span> inside the footer template. Chromium substitutes the values at print time. CSS counters do not work.

Can I use CSS variables in Playwright headerTemplate? No. The header iframe is a separate document with no access to the main page's CSS variables, stylesheets, or fonts. Inline everything, or include a <link rel="stylesheet"> tag inside the template.

How do I add a logo to a Playwright PDF header? Base64-encode the image and use a data:image/png;base64,... URL inside an <img> tag in the template string. External image URLs are fragile in the header iframe.

Why does Puppeteer have the same header limitations? Both libraries wrap the Chrome DevTools Protocol Page.printToPDF method. The four footguns are Chromium behaviors, not Playwright or Puppeteer behaviors, and apply identically in both.

What is the difference between margin.top and the header height? margin.top reserves blank space where the header renders. If the header is taller than margin.top, it clips. Set margin.top to header height plus 5mm of breathing room.

How do I make a multi-line header in Playwright? Either stack inline-styled <div> rows inside headerTemplate and increase margin.top to fit, or wrap the header in <table><thead>...</thead></table> in the main page DOM and skip displayHeaderFooter.

Does the thead trick work with Puppeteer? Yes. <thead> and <tfoot> repetition is implemented in Chromium itself (CSS 2.1 paged media), so any tool driving Chromium for PDF generation gets the same behavior.

Why do my headerTemplate styles get ignored? The header template is a sandboxed iframe with its own document. It ignores main-page stylesheets and <style> tags. Only inline styles and <link> or <style> tags inside the template string apply.

Further reading: the original GitHub threads at microsoft/playwright#14441 and microsoft/playwright#29573, the Playwright page.pdf() API reference, the Puppeteer pdf() API reference, and the CSS 2.1 paged media specification. For deeper coverage of the surrounding CSS, see the CSS print styles guide and the Playwright vs Puppeteer comparison.

Free tools mentioned:

Html To PdfTry it free

Start generating PDFs

Build PDF templates with a visual editor. Render them via API from any language in ~300ms.