ScreenshotNeo

BlogHTML to image & PDF

How to Use CSS Counters with wkhtmltopdf

Learn how CSS counters work in wkhtmltopdf, number nested headings, avoid scope bugs, and add reliable Page X of Y footers.

By the ScreenshotNeo team30 September 20268 min read

How to Use CSS Counters with wkhtmltopdf

Direct answer: CSS counters work in wkhtmltopdf when you reset them on stable elements, increment elements that generate boxes, and emit the value with counter() or counters() in generated content. For physical PDF page numbers, use wkhtmltopdf’s documented [page] and [topage] footer substitutions instead of relying on CSS Paged Media counters.

This guide shows a complete pattern for chapters and nested sections, explains counter scope, covers the wkhtmltopdf options that affect output, and lists the failure modes that make numbering appear correct in a browser but wrong in the PDF.

1. The CSS counter model

A counter has three operations:

  • counter-reset creates or reinitializes a counter.
  • counter-increment changes its value, normally by one.
  • counter(name) reads one counter; counters(name, separator) reads the nested stack of counters.

Generated content normally displays the value through ::before or ::after. Counter scope follows the document’s generated box tree. A reset on an ancestor establishes the scope for descendants and following siblings. An element with display:none generates no box and cannot set, reset, or increment a counter, as specified by the W3C CSS Lists and Counters specification.

Minimal working example

<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <style>
    body { counter-reset: chapter; }
    h1 {
      counter-increment: chapter;
      counter-reset: section;
    }
    h1::before {
      content: "Chapter " counter(chapter) ". ";
    }
    h2 {
      counter-increment: section;
    }
    h2::before {
      content: counter(chapter) "." counter(section) " ";
    }
  </style>
</head>
<body>
  <h1>First chapter</h1>
  <h2>First section</h2>
  <h2>Second section</h2>
  <h1>Second chapter</h1>
  <h2>First section</h2>
</body>
</html>

The output is Chapter 1, sections 1.1 and 1.2, then Chapter 2 and section 2.1. Resetting section on each h1 makes the nested number restart for every chapter.

2. Number headings and nested sections

Keep the reset on the heading that owns the nested scope, not only on its pseudo-element. This makes the counter available to later sibling headings. You can add a third level by resetting and incrementing another counter:

Counters reset at chapter boundaries and increment for each nested section.
Counters reset at chapter boundaries and increment for each nested section.
<style>
  body { counter-reset: chapter; }
  h1 {
    counter-increment: chapter;
    counter-reset: section;
  }
  h1::before {
    content: counter(chapter) ". ";
  }
  h2 {
    counter-increment: section;
    counter-reset: subsection;
  }
  h2::before {
    content: counter(chapter) "." counter(section) " ";
  }
  h3 {
    counter-increment: subsection;
  }
  h3::before {
    content: counter(chapter) "." counter(section) "." counter(subsection) " ";
  }
</style>

For reusable outline numbering, counters() can print every active instance of one counter. This is useful when sections are nested at arbitrary depth:

ol.outline {
  counter-reset: item;
  list-style: none;
}
ol.outline li {
  counter-increment: item;
}
ol.outline li::before {
  content: counters(item, ".") " ";
}

Use generated content for numbering only. Keep the actual heading text in the HTML so screen readers, text extraction, bookmarks, and search still see meaningful content.

3. Counter scope, wrappers, and hidden content

Counter bugs are usually scope bugs. A counter is affected by the order of generated boxes, so adding a wrapper can change which reset an element sees. Start with adjacent headings, confirm the PDF, and introduce layout wrappers one at a time.

  • Reset a document-level counter on body or another stable root.
  • Reset a chapter-level counter on each h1 that starts a chapter.
  • Do not put a required reset only on h1::before; the pseudo-element is not the right owner for following siblings.
  • Do not use display:none on an element that must increment a counter. Use visibility:hidden or visually hide generated content while preserving the box if that is really required.
  • Use explicit classes when not every heading belongs to the outline, for example h1.chapter and h2.section.

A community report describes duplicate numbering when headings are placed in separate wrapper div elements, while adjacent headings worked. Treat this as a renderer-specific compatibility report, not as a CSS rule. Reproduce it with the same wrapper structure and wkhtmltopdf binary used in production.

4. Generate a PDF with wkhtmltopdf

Save your HTML as guide.html, then run:

wkhtmltopdf guide.html guide.pdf

For local assets, use an absolute file path or enable local file access where your packaged binary requires it:

wkhtmltopdf \
  --enable-local-file-access \
  --encoding utf-8 \
  guide.html guide.pdf

Pin the wkhtmltopdf version in CI and deployment. Different builds can include different Qt/WebKit patches, fonts, and defaults. Always inspect the generated PDF rather than trusting a browser preview.

5. Add reliable Page X of Y numbers

wkhtmltopdf documents header and footer substitutions for physical page numbers. The production-safe pattern is:

wkhtmltopdf footer substitutions provide physical Page X of Y numbering.
wkhtmltopdf footer substitutions provide physical Page X of Y numbering.
wkhtmltopdf \
  --footer-right 'Page [page] of [topage]' \
  guide.html guide-numbered.pdf

[page] is the current page and [topage] is the final page. The same interface documents controls such as pageOffset and pagesCount; see the wkhtmltopdf usage documentation for the options supported by your release.

You can style a footer with a separate HTML document:

<!-- footer.html -->
<!doctype html>
<html><body style="font:10px sans-serif; text-align:right; margin:0 18mm 0 0;">
  Page [page] of [topage]
</body></html>
wkhtmltopdf \
  --footer-html footer.html \
  --margin-bottom 18mm \
  guide.html guide.pdf

Reserve enough bottom margin for the footer. If the margin is too small, text can overlap the body or be clipped.

6. CSS Paged Media counters: when to use them

CSS Paged Media defines page-associated page and pages counters for conforming paged-media user agents. wkhtmltopdf’s documented and widely used interface is its header/footer substitution system, however. Test any @page counter rule against the exact wkhtmltopdf binary before making it a production dependency:

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

If this rule is ignored, the PDF can still have reliable numbering through --footer-right 'Page [page] of [topage]'. Keep the two mechanisms separate: CSS counters number your document structure; wkhtmltopdf substitutions number physical PDF pages.

7. A repeatable implementation checklist

  1. Create a minimal HTML file with adjacent h1 and h2 elements.
  2. Put the top-level counter-reset on body.
  3. Put each nested reset on the heading that owns that scope.
  4. Confirm every incrementing element is displayed and generates a box.
  5. Render with the pinned wkhtmltopdf binary.
  6. Add wrappers, columns, tables, and page-break rules one at a time.
  7. Add the footer substitution and increase the corresponding page margin.
  8. Open the PDF, copy text from it, and check the first, middle, and final pages.

8. Troubleshooting common failures

Numbers never appear

Cause: The pseudo-element has no content, the stylesheet was not loaded, or the counter name differs between reset, increment, and output.

Fix: Use a literal test such as h1::before { content: "TEST "; }, check the HTML source path, and compare counter names character by character.

The first heading starts at zero

Cause: The counter is read before an increment or is reset after the incrementing element.

Fix: Place counter-increment on the heading and counter-reset on its parent or before the heading in document order.

Nested sections do not restart

Cause: The nested counter was reset once on body, so it continues across chapters.

Fix: Put counter-reset: section on every chapter heading, normally the h1 element.

Some headings are skipped

Cause: A heading or ancestor has display:none, or a print stylesheet changes its display.

Fix: Ensure the element participates in layout. Check both the normal and @media print rules.

Duplicate numbers appear after adding wrappers

Cause: A wrapper introduces another reset or exposes renderer-sensitive behavior.

Fix: Reduce the document to adjacent headings, then add the wrappers individually. Keep the reset on a stable ancestor and pin the renderer version.

Page numbers show only the current page

Cause: The footer uses [page] without [topage], or the footer is not being processed by the selected option.

Fix: Use --footer-right 'Page [page] of [topage]' and verify the command-line version supports headers and footers.

Cause: The bottom margin is smaller than the footer’s height.

Fix: Increase --margin-bottom, reduce footer font size, and render again.

9. Performance, reliability, and cost

Counter calculations are lightweight; the expensive parts of a PDF job are loading external pages, executing JavaScript, fetching images and fonts, and laying out long documents. Keep assets local or on dependable origins, avoid unnecessary third-party requests, and reuse a pinned renderer image in CI. For deterministic output, specify the encoding, page size, margins, fonts, and print stylesheet explicitly.

Long documents can consume substantial memory because layout and pagination happen across the whole document. Split very large reports into bounded jobs when your workflow permits it, then merge PDFs in a separate step. Compare output from the same binary, operating system, fonts, and locale when diagnosing differences.

wkhtmltopdf itself does not charge per page. Your cost is compute, storage, and any hosted browser or PDF service you add around it. If you use a hosted screenshot or PDF API, check whether failed loads, bot checks, and cache hits are billed and whether the service exposes a response verdict.

10. Or skip the browser setup

ScreenshotNeo provides a website screenshot API and MCP server. It can return PNG, JPEG, WebP, or PDF from one request, while handling browser setup for you. Cookie and consent banners, newsletter popups, and chat widgets are removed before capture. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing state.

For a PDF or image capture, start with the API examples in the ScreenshotNeo documentation.

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,
)
r.raise_for_status()
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}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const fs = await import('node:fs/promises');
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));

ScreenshotNeo also supports full-page captures with lazy images loaded, element selectors, custom CSS and JavaScript, waits, request blocking, cookies and headers, device presets, retina scale, PDF paper and margin settings, caching TTLs, signed links, asynchronous jobs, webhooks, bulk capture, usage reporting, and an MCP server with take_screenshot, get_page_info, and capture_pdf for AI clients. It offers 1,000 shots per month free with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.

11. FAQ

Can CSS counters create a table of contents?

They can number headings in the document, but a linked table of contents also needs anchors and generated links. Build the navigation structure in HTML and use counters only for the visible labels.

Why does browser output differ from wkhtmltopdf?

wkhtmltopdf uses a packaged WebKit engine whose CSS and pagination behavior can differ from a current browser. Test the exact binary, fonts, and print stylesheet used in deployment.

Should I use counter(page) for wkhtmltopdf footers?

Use the documented [page] and [topage] substitutions for the dependable wkhtmltopdf production path. Validate CSS Paged Media rules separately if you need them.

Can I hide the generated number for one heading?

Override that heading’s pseudo-element with content: none, or assign a class and exclude it from the counter rules. Do not set the heading itself to display:none if it must affect another counter.