Invoice (A4 multi-page)
A B2B invoice with 50 line items that paginates across three A4 pages, with header / footer that repeat and a totals card that sits flush at the end.
/** * Multi-page invoice: 50 line items so the doc paginates to ~3 A4 pages. * Used to surface week-4 pagination issues — break-inside, repeated * headers/footers, page numbers — that the single-page Invoice can't show. */import type { PageContext } from "react-print-pdf";const skus = [ "Indie license", "Onboarding session", "Font embedding setup", "Priority support", "Custom theme dev", "Migration assist", "Performance audit", "PDF style review", "Layout consultation", "Accessibility pass",];const items = Array.from({ length: 50 }, (_, i) => ({ sku: `PRT-${String(i + 1).padStart(3, "0")}`, desc: skus[i % skus.length]!, qty: ((i * 7) % 5) + 1, unit: 25 + ((i * 13) % 8) * 15,}));function fmt(n: number) { return `$${n.toFixed(2)}`;}export function MultiPageInvoiceHeader(ctx: PageContext) { return ( <div className="flex items-baseline justify-between border-b border-gray-200 pb-2 text-xs text-gray-500"> <span className="font-semibold text-gray-900">Invoice #2026-XL</span> <span>react-print-pdf · multi-page test</span> <span> Page {ctx.pageNumber} of {ctx.totalPages} </span> </div> );}export function MultiPageInvoiceFooter(ctx: PageContext) { return ( <div className="border-t border-gray-200 pt-2 text-center text-[10px] text-gray-400"> Generated by react-print-pdf · MIT license · page {ctx.pageNumber} / {ctx.totalPages} </div> );}export function MultiPageInvoice() { const subtotal = items.reduce((sum, it) => sum + it.qty * it.unit, 0); const tax = subtotal * 0.08; const total = subtotal + tax; return ( <article data-testid="multi-page-invoice" className="w-full mx-auto"> <header className="mb-8 flex items-start justify-between border-b border-gray-200 pb-6"> <div> <h2 className="text-2xl font-bold text-gray-900">Invoice #2026-XL</h2> <p className="mt-1 text-sm text-gray-500">Issued April 28, 2026 · 50 items</p> </div> <div className="text-right"> <p className="text-sm font-semibold text-blue-600">react-print-pdf</p> <p className="text-xs text-gray-500">Multi-page test fixture</p> </div> </header> <section className="mb-6"> <table className="w-full text-sm"> <thead data-print-repeat> <tr className="border-b border-gray-300 bg-white text-left text-xs uppercase tracking-wide text-gray-500"> <th className="py-2 font-medium">SKU</th> <th className="py-2 font-medium">Description</th> <th className="py-2 font-medium">Qty</th> <th className="py-2 font-medium">Unit</th> <th className="py-2 font-medium">Amount</th> </tr> </thead> <tbody> {items.map((it) => ( <tr key={it.sku} className="border-b border-gray-100"> <td className="py-2 font-mono text-xs text-gray-500">{it.sku}</td> <td className="py-2 text-gray-900">{it.desc}</td> <td className="py-2 text-gray-700">{it.qty}</td> <td className="py-2 text-gray-700">{fmt(it.unit)}</td> <td className="py-2 font-medium text-gray-900">{fmt(it.qty * it.unit)}</td> </tr> ))} </tbody> </table> </section> <section className="ml-auto w-64 space-y-1 border-t border-gray-300 pt-4 text-sm"> <div className="flex justify-between text-gray-600"> <span>Subtotal</span> <span>{fmt(subtotal)}</span> </div> <div className="flex justify-between text-gray-600"> <span>Tax (8%)</span> <span>{fmt(tax)}</span> </div> <div className="flex justify-between border-t border-gray-200 pt-2 font-semibold text-gray-900"> <span>Total</span> <span>{fmt(total)}</span> </div> </section> </article> );}exportToPDF, so the page breaks you see here are the page breaks you'll get in the file.What you're looking at
The invoice that every SaaS, agency, and consultancy ends up needing. 50 line items, three pages, header and footer that repeat on every page, and a totals card that stays glued to the end of the line items (not stranded on a page of its own).
Features in play:
- Pagination. Line items don't fit on one page, so the paginator splits the table at the next safe row boundary.
- Repeating header. The doc header (invoice number, date, totals page indicator) renders at the top of every page automatically. You supply it once.
- Repeating footer. "Page N of M" with the document footer text, also automatic.
<thead data-print-repeat>. The table's column headers (SKU / Description / Qty / Unit / Amount) repeat at the top of each new page so a reader on page 3 still knows which column is which.break-inside: avoidon the totals card. Keeps Subtotal / Tax / Total together, never splits them mid-page.- Page numbers in the header AND footer. They get the current page context (
pageNumber,totalPages) injected at render time.
The exporter call
This is where the React DX layer earns its keep \u2014 you don't compute page count, you don't loop, you don't reach for templates. You hand it a component and a header / footer:
import { usePDFExport, Printable, ExportButton } from "react-print-pdf/react";
import type { PageContext } from "react-print-pdf";
function InvoicePage({ invoice }) {
const exporter = usePDFExport({
fileName: `invoice-${invoice.number}.pdf`,
paperSize: "A4",
margins: { top: 56, right: 48, bottom: 48, left: 48 },
header: ({ pageNumber, totalPages }: PageContext) => (
<InvoiceHeader number={invoice.number} page={pageNumber} of={totalPages} />
),
footer: ({ pageNumber, totalPages }: PageContext) => (
<p className="text-center text-[10px] text-gray-400">
Generated by react-print-pdf \u00b7 page {pageNumber} / {totalPages}
</p>
),
headerHeight: 40,
footerHeight: 24,
});
return (
<>
<ExportButton target={exporter.ref}>Download invoice</ExportButton>
<Printable ref={exporter.ref} options={exporter.options}>
<Invoice data={invoice} />
</Printable>
</>
);
}Three things to note:
- The
headerandfooterare functions ofPageContext, not static elements. That's how you get page numbers without writing a paginator yourself. headerHeightandfooterHeightreserve the vertical space on every page. The paginator then knows the actual content area ispaperHeight - margin.top - margin.bottom - headerHeight - footerHeight.Printableauto-sizes its child topageContentWidth(options)so the on-screen preview lays out exactly the way the PDF will.
The component
The shape:
export function Invoice({ data }) {
const subtotal = data.items.reduce((s, it) => s + it.qty * it.unit, 0);
const tax = subtotal * 0.08;
const total = subtotal + tax;
return (
<article className="w-full mx-auto">
<DocHeader number={data.number} date={data.date} />
{/* The line-items table. <thead data-print-repeat> tells the
paginator to re-render the column headers on every page. */}
<section className="mb-6">
<table className="w-full text-sm">
<thead data-print-repeat>
<tr className="border-b border-gray-300 text-left text-xs uppercase tracking-wide text-gray-500">
<th>SKU</th><th>Description</th><th>Qty</th><th>Unit</th><th>Amount</th>
</tr>
</thead>
<tbody>
{data.items.map((it) => (
<tr key={it.sku} className="border-b border-gray-100">
<td className="font-mono">{it.sku}</td>
<td>{it.desc}</td>
<td>{it.qty}</td>
<td>{fmt(it.unit)}</td>
<td className="font-medium">{fmt(it.qty * it.unit)}</td>
</tr>
))}
</tbody>
</table>
</section>
{/* Totals card. break-inside: avoid keeps these three rows
together regardless of where the table ends on the page. */}
<section
className="ml-auto w-64 space-y-1 border-t border-gray-300 pt-4 text-sm"
style={{ breakInside: "avoid" }}
>
<Row label="Subtotal" value={fmt(subtotal)} />
<Row label="Tax (8%)" value={fmt(tax)} />
<Row label="Total" value={fmt(total)} bold />
</section>
</article>
);
}Why the choices look the way they do
<thead data-print-repeat> instead of CSS
There is a CSS spec for this (<thead> is supposed to repeat on print). The reason we wrote a data- attribute instead of relying on CSS is that we needed an unambiguous signal in the walker. CSS print behavior varies enough between engines that depending on it for a library invariant turns out to bite. The data attribute is two characters longer and zero ambiguity.
Totals card width pinned to w-64
Because the totals card sits beside whitespace on the left (and ml-auto pushes it to the right), giving it a fixed width keeps the layout predictable when content changes. If your totals card has many more rows (per-tax-jurisdiction breakdown, surcharges), bump w-64 to whatever fits. Don't let it flex \u2014 floating widths around break-inside: avoid is where surprises live.
One footer string, not three
The footer is intentionally just one centered string. Resist putting the company VAT number, payment terms, support email, and the SaaS marketing tagline all in the footer. That stuff belongs in a "terms" block on the last page (use break-before: page to push it onto its own page). A clean footer with page numbers reads as professional.
Variations
The fixture is single-currency, single-tax-rate. Common modifications:
- Multi-currency. Replace
$with a passed-in currency symbol; format withIntl.NumberFormatif you want locale-aware separators. - Per-row discount. Add a
discountcolumn and a discount-applied subtotal row. The totals card width may need to grow. - Stamps and signatures. A "Paid" or "Overdue" stamp goes inside
<article>withposition: absolute; transform: rotate(-12deg). Transforms force raster fallback \u2014 that's usually what you want for a stamp anyway. - Multiple tax rows. Add rows to the totals card; the
break-inside: avoidkeeps them grouped.
Related
- Pagination \u2014
<thead data-print-repeat>, break hints, repeat bands - Best practices \u2014 width-matching, font registration
- Sizing and alignment \u2014
pageContentWidth(options)