Building an HTML invoice template that prints right
Most invoice templates are built in a browser, look correct, and then fall apart the moment they become a PDF: the table header vanishes on page two, a line item splits across the page break, and the totals block ends up orphaned on a page of its own.
That is not a bug in your CSS. It is that print CSS is a different layout model, and browsers hide most of it from you.
Size the page first
A print stylesheet starts with @page, which has no equivalent on the web:
@page {
size: A4; /* or Letter, or 210mm 297mm */
margin: 18mm 16mm 22mm;
}
Everything downstream inherits from this. Get it wrong and every other measurement is fighting it.
Use absolute units. px is a screen unit; in a paginated context it is
defined as 1/96in, which is fine until someone renders at a different scale.
Millimetres and points are unambiguous, and an invoice is a physical document —
it may genuinely be printed and filed.
Make the table header repeat
This is the single most common defect in a multi-page invoice. A <table> that
runs past one page drops its header, and page two becomes a wall of unlabelled
numbers.
The fix is markup, not styling: put the header row in <thead> and the rows in
<tbody>, and the print engine repeats the header on every page automatically.
<table class="lines">
<thead>
<tr><th>Description</th><th>Qty</th><th>Unit</th><th>Amount</th></tr>
</thead>
<tbody>
<tr><td>Consulting</td><td>10</td><td>150.00</td><td>1,500.00</td></tr>
</tbody>
</table>
If you build the header out of <div>s, no engine can help you.
Stop rows splitting
A line item cut in half across a page boundary looks like a printing error to the person receiving it:
.lines tr { break-inside: avoid; }
.totals-box { break-inside: avoid; }
break-inside: avoid on the totals block matters too. A summary separated from
its own invoice is the one page a customer will query.
Align the money column
Proportional digits make a column of figures ragged, because 1 is narrower
than 8. One declaration fixes it:
.amount {
text-align: right;
font-variant-numeric: tabular-nums;
}
This is the difference between a document that looks typeset and one that looks generated.
Give the label room
A totals row is usually display: flex; justify-content: space-between, which
works right up until the label is long — “GST 18% — Stationery & Gifting” — and
then the label runs into the amount with no gap at all. space-between gives no
gutter when the content already fills the row.
.totals-box .row .k { padding-right: 20px; min-width: 0; }
.totals-box .row .v { white-space: nowrap; flex: 0 0 auto; }
The amount must never wrap; the label may.
The preview lies
The most expensive habit is checking your template in a browser and assuming the PDF matches. It frequently does not — print engines implement a different subset of CSS, and the modern layout features you reach for on the web are exactly the ones with patchy support.
Render to an actual PDF on every change, and check page two. Most defects only exist on page two.
If you would rather not maintain a print stylesheet at all, the Invoice PDF API takes JSON and returns a finished PDF from six ready-made templates — the print CSS is already solved, including all of the above.