Guide

Headers, Footers, Cover Pages and Contents in Markdown PDFs

Published July 30, 2026

A Markdown document has no concept of a page, so it has no concept of anything that repeats per page. Headers, footers and page numbers are not content — they are furniture that the pagination engine has to generate. This guide covers how to produce all of it from CSS, plus the two structural elements Markdown also cannot express: a cover page and a table of contents.

Everything here builds on the CSS Paged Media model. If page breaks and @page are unfamiliar, start with the print CSS guide and come back.

Page margin boxes

The @page rule does not just set dimensions — it defines sixteen addressable regions around the content area, called margin boxes. They are where headers and footers live.

The four you will actually use:

@page {
  size: A4;
  margin: 25mm 20mm;

  @top-left    { content: "Quarterly Report"; }
  @top-right   { content: "Confidential"; }
  @bottom-left { content: "Acme Corporation"; }
  @bottom-right{ content: counter(page); }
}

There are also @top-center and @bottom-center, plus corner boxes (@top-left-corner, @bottom-right-corner, and so on) and left/right side boxes for vertical text. The corners sit in the margin intersections and are genuinely rarely useful.

Critically, margin boxes live inside the margin you declared. A 25mm top margin with a header in @top-left means the header occupies part of that 25mm, and your body text still starts at 25mm. If the header collides with your content, the margin is too small — increase it rather than trying to reposition the box.

Margin boxes take their own styling:

@page {
  @bottom-center {
    content: counter(page);
    font-family: "Inter", sans-serif;
    font-size: 9pt;
    color: #666;
    vertical-align: middle;
  }
}

Engine support, honestly

This is the area where engines diverge most sharply, and it is worth knowing before you build on it:

EngineMargin box support
Paged.jsFull — all sixteen boxes, running elements, counters
WeasyPrintFull, very solid
PrinceXMLFull (it largely defined the model)
Chrome / Puppeteer printNone. @page margin boxes are ignored entirely
Firefox printNone

That Chrome row surprises people. Headless Chrome — the engine behind most Node-based Markdown-to-PDF tools — does not implement @page margin boxes at all. Puppeteer works around this with its own headerTemplate and footerTemplate options in page.pdf(), which are separate HTML fragments injected outside the CSS model:

await page.pdf({
  path: "out.pdf",
  format: "A4",
  displayHeaderFooter: true,
  headerTemplate: `<div style="font-size:9px; width:100%; text-align:center;">
                     Quarterly Report
                   </div>`,
  footerTemplate: `<div style="font-size:9px; width:100%; text-align:center;">
                     <span class="pageNumber"></span> of <span class="totalPages"></span>
                   </div>`,
  margin: { top: "25mm", bottom: "25mm", left: "20mm", right: "20mm" },
});

Puppeteer recognises the special classes pageNumber, totalPages, date, title and url inside those templates. Note that the templates do not inherit your page’s stylesheet — you must inline the styles, which is why that snippet looks the way it does.

So: if you need real CSS margin boxes, use a Paged.js or WeasyPrint pipeline. If you are on Puppeteer, use its template options and accept the reduced flexibility.

Page numbers

Inside a margin box, counter(page) gives the current page number. The pages counter gives the total:

@page {
  @bottom-center {
    content: "Page " counter(page) " of " counter(pages);
  }
}

counter(pages) requires the engine to know the final page count before rendering the first page, which means a two-pass layout. Paged.js and WeasyPrint do this. It is another thing Chrome’s native pipeline cannot do, hence Puppeteer’s totalPages span.

Suppressing the number on the cover

A page number on a title page looks wrong. Target the first page:

@page :first {
  @bottom-center { content: none; }
}

Restarting or offsetting numbering

Front matter conventionally uses roman numerals, with arabic numbering starting at the first content page:

@page frontmatter {
  @bottom-center {
    content: counter(page, lower-roman);
  }
}

.frontmatter { page: frontmatter; }

/* Reset the counter where the body proper begins. */
.body-start {
  counter-reset: page 1;
}

counter() takes any CSS list style as its second argument: decimal, lower-roman, upper-roman, lower-alpha, upper-alpha.

Running headers from your content

A static header string is easy. What you usually want is a header that reflects the current section — “Chapter 3: Deployment” — and updates as the document progresses. That is a running element, and it works through the string-set property.

h1 {
  string-set: chapter content();
}

h2 {
  string-set: section content();
}

@page {
  @top-left  { content: string(chapter); }
  @top-right { content: string(section); }
}

string-set: chapter content() means “whenever an h1 is encountered, store its text content in a named string called chapter”. string(chapter) then retrieves the most recent value at the point each page is laid out.

The retrieval has a second argument controlling which value is used when several occur on one page:

@top-left { content: string(chapter, first); }  /* first on this page */
@top-left { content: string(chapter, last); }   /* last on this page — default */
@top-left { content: string(chapter, start); }  /* value at page start */

first is usually what you want for chapter headers: if a page contains the end of chapter 2 and the start of chapter 3, labelling it “Chapter 2” matches how readers scan.

Element-based running headers

For richer content than a text string — a logo, a multi-line block — position: running() moves an entire element into a margin box:

.doc-header {
  position: running(docheader);
}

@page {
  @top-center {
    content: element(docheader);
  }
}

The element is removed from normal flow and rendered in the margin box on every page. In your Markdown:

<div class="doc-header">
  <strong>Acme Corp</strong> — Internal Engineering Handbook
</div>

This is Paged.js and PrinceXML territory. WeasyPrint’s support is partial. It is the most powerful option available and the least portable.

Building a cover page

Markdown has no title page construct, so a cover is just content with a forced break after it and styling that fills the page.

<div class="cover">

# Infrastructure Migration Plan

## Phase 2 — Database Consolidation

**Prepared by:** Platform Engineering
**Date:** March 2026
**Version:** 2.1
**Classification:** Internal

</div>
.cover {
  break-after: page;
  page-break-after: always;
  height: 100%;
  display: flex;
  flex-direction: column;
  justify-content: center;
  text-align: center;
}

.cover h1 {
  font-size: 30pt;
  line-height: 1.2;
  margin-bottom: 6pt;
  break-after: auto; /* override the global avoid */
}

.cover h2 {
  font-size: 15pt;
  font-weight: 400;
  color: #555;
  margin-bottom: 40pt;
}

.cover p {
  font-size: 10.5pt;
  line-height: 1.9;
}

Two things to flag. First, height: 100% on the cover is the mechanism that pushes the metadata block to vertical centre — combined with the flex centring, it fills the page box. Second, flex is safe here specifically because a cover is guaranteed to be one page; this is exactly the “small atomic unit” exception discussed in the print CSS guide.

The break-after: auto override on .cover h1 matters because a global h1 { break-after: avoid } rule would otherwise fight the cover’s forced break.

Vertical centring without flex

If your engine handles flex poorly even for single-page units:

.cover {
  break-after: page;
  padding-top: 30%;
  text-align: center;
}

Crude, but reliable everywhere.

Automatic tables of contents

There are two approaches, and which you use depends on whether your tool generates the TOC for you.

If your converter generates it

Most Markdown-to-PDF tools have a TOC option that walks the heading tree and emits a nested list of links. That is the right choice when available — it stays in sync automatically. The formatting question then becomes how to add page numbers, since a screen TOC has links but no numbers.

Leader dots and page numbers

The target-counter() function resolves a link’s target to its page number:

.toc a::after {
  content: target-counter(attr(href), page);
  float: right;
}

Add leader dots with leader():

.toc a::after {
  content: leader(dotted) " " target-counter(attr(href), page);
}

leader() fills the space between the text and the number with repeating dots — the classic printed-book look. Values are dotted, solid, space, or a literal string like leader(".").

Both target-counter() and leader() are Paged.js / WeasyPrint / Prince features. Neither works in Chrome’s native pipeline, where a TOC can only realistically carry links, not page numbers.

Full styling for a generated TOC:

.toc {
  break-after: page;
  page-break-after: always;
}

.toc h2 {
  font-size: 18pt;
  margin-bottom: 14pt;
}

.toc ul {
  list-style: none;
  padding-left: 0;
}

.toc ul ul {
  padding-left: 14pt;
}

.toc li {
  margin: 3pt 0;
  font-size: 10.5pt;
}

.toc a {
  text-decoration: none;
  color: inherit;
}

/* Top-level entries stand out. */
.toc > ul > li > a {
  font-weight: 600;
}

Writing one by hand

For a short document, a hand-written TOC is not unreasonable — and it is the only option if your pipeline generates nothing. Markdown heading anchors are predictable in most parsers: lowercase, spaces to hyphens, punctuation dropped.

## Contents

1. [Executive summary](#executive-summary)
2. [Current architecture](#current-architecture)
   1. [Data layer](#data-layer)
   2. [Service mesh](#service-mesh)
3. [Migration phases](#migration-phases)

The obvious cost is that it silently rots. Rename a heading and the link breaks with no warning. For anything you will revise more than twice, use a generator.

Heading depth

A TOC listing every h4 and h5 in a long document is noise. Two or three levels is the useful range. If your tool exposes a depth setting, cap it at h3. If not, hide the deeper levels:

.toc ul ul ul { display: none; }

Line numbers on code blocks

Line numbers help when prose refers to specific lines (“the retry on line 14”). They are pure decoration otherwise, and they carry a real cost: if a reader copies the block, they get the numbers too — unless you generate them correctly.

The right technique uses CSS counters and ::before, so the numbers exist as generated content and are never part of the selectable text:

pre.line-numbers {
  counter-reset: line;
  padding-left: 0;
}

pre.line-numbers code {
  display: block;
}

pre.line-numbers .line {
  counter-increment: line;
  display: block;
  padding-left: 3.2em;
  position: relative;
}

pre.line-numbers .line::before {
  content: counter(line);
  position: absolute;
  left: 0;
  width: 2.4em;
  text-align: right;
  color: #999;
  user-select: none;
  -webkit-user-select: none;
}

This needs each line wrapped in an element with class line, which means a syntax highlighter that emits per-line markup. Highlight.js does not do this by default; Shiki and Prism both can. If your highlighter emits one flat blob, you cannot add per-line numbers with CSS alone — there are no line elements to count.

A pure-CSS alternative that avoids the highlighter question entirely, at the cost of an extra element:

<pre class="numbered"><code>const retry = 3;
await connect({ retry });
</code></pre>
pre.numbered {
  counter-reset: line;
}
pre.numbered code {
  /* Each newline in the source becomes a counted line via
     the sibling structure your highlighter produces.       */
}

In practice, if line numbers matter to you, pick a highlighter that emits line elements. Fighting CSS to fake structure that is not in the DOM is not worth it.

Do not use a border for the gutter

A tempting shortcut is a left border on pre to suggest a gutter. It looks fine until the block breaks across a page, at which point the border stops and restarts, drawing attention to exactly the break you did not want noticed. Use background on the pseudo-element gutter instead, or nothing.

Putting it together

A complete furniture stylesheet for a formal report:

@page {
  size: A4;
  margin: 28mm 20mm 25mm;

  @top-left {
    content: string(chapter, first);
    font-size: 8.5pt;
    color: #666;
  }

  @top-right {
    content: "Internal";
    font-size: 8.5pt;
    color: #999;
    letter-spacing: 0.06em;
    text-transform: uppercase;
  }

  @bottom-center {
    content: counter(page) " / " counter(pages);
    font-size: 9pt;
    color: #666;
  }
}

/* No furniture on the cover. */
@page :first {
  margin: 20mm;
  @top-left     { content: none; }
  @top-right    { content: none; }
  @bottom-center{ content: none; }
}

h1 { string-set: chapter content(); }

.cover {
  break-after: page;
  height: 100%;
  display: flex;
  flex-direction: column;
  justify-content: center;
  text-align: center;
}
.cover h1 { break-after: auto; font-size: 30pt; }

.toc { break-after: page; }
.toc ul { list-style: none; padding-left: 0; }
.toc ul ul { padding-left: 14pt; }
.toc a { text-decoration: none; color: inherit; }
.toc a::after {
  content: leader(dotted) " " target-counter(attr(href), page);
}

Try it yourself

The editor here runs on Paged.js, so margin boxes, string-set running headers, counter(pages) and target-counter() all work — including in the live preview, which means you can see a running header update as you scroll rather than discovering it after export. Built-in themes include header, footer and TOC styling you can start from.

Related: print CSS and pagination for the layer underneath this, and typography and styling for fonts and colour.

About this guide

Written and maintained by the developer of MarkdownToFile — the browser-based converter this site runs on. Techniques described here are tested against the engines named in the text (Chromium's print pipeline, Paged.js, WeasyPrint), and engine limitations are stated rather than glossed over. Corrections are welcome via the contact page; more about the project on the about page.

Try it yourself — free, no signup

Convert your Markdown to a polished PDF right in your browser.

Open the editor