PDFreact-print-pdf
DocsBrowser-only alpha

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.

Edit this page

Live multi-page invoice preview\u00b7 A4

StackBlitzGitHub Workshop
Click Export PDF to download a real vector PDF. The on-screen pixels match the PDF output exactly.
/** * 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>  );}
Live <PrintPreview> · A4Open in new tab
Pages are rendered inline as scaled DOM — no PDF round-trip. The same paginator drives 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: avoid on 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:

  1. The header and footer are functions of PageContext, not static elements. That's how you get page numbers without writing a paginator yourself.
  2. headerHeight and footerHeight reserve the vertical space on every page. The paginator then knows the actual content area is paperHeight - margin.top - margin.bottom - headerHeight - footerHeight.
  3. Printable auto-sizes its child to pageContentWidth(options) so the on-screen preview lays out exactly the way the PDF will.

The component

Full source on GitHub.

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.

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 with Intl.NumberFormat if you want locale-aware separators.
  • Per-row discount. Add a discount column 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> with position: 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: avoid keeps them grouped.

On this page