Skip to content
WingoUI
ComponentsTemplatesDocsPricingAI agentsBlogChangelog
  1. Home
  2. Blog
  3. Build it: screens and apps
  4. React Invoice Template: Line Items, VAT and Print View
Auf Deutsch lesen
  1. Blog
  2. Build it: screens and apps
  3. React Invoice Template: Line Items, VAT and Print View
Build it: screens and apps
Build it: screens and apps

React Invoice Template: Line Items, VAT and Print View

SR
Serban Rusu · Founder of Wingo UI
Oct 16, 2026 · 10 min read

On this page

0%
  1. What does a React invoice template need?
  2. How do I build the invoice line items editor?
  3. How should discounts work on an invoice?
  4. Why compute VAT per rate instead of per line?
  5. How do I make a clean print view for the invoice?
    1. How do I print only the invoice, not the app?
  6. What does the final invoice editor look like?
  7. How do I save the invoice without trusting the browser's totals?
  8. Can I start from a complete invoice page instead?
  9. Next step
  10. FAQ
    1. How do I make a printable invoice in React?
    2. Should I use react-to-print or @react-pdf/renderer for invoices?
    3. How do I apply a discount to the whole invoice when lines have different tax rates?
    4. Should invoice tax be calculated per line or per rate?
    5. Is there a free React invoice template?

TL;DR

A React invoice template is two views over one invoice object: an editor where the lines behave like a spreadsheet, and a print sheet with fixed paper colors that the browser turns into a PDF. Compute every total in one function that applies line discounts, shares a document discount across the lines before tax, and rounds the tax once per rate, then call that same function on the server before you save.

Published Oct 16, 2026

A typical react invoice template is a form with a table and one tax field under the subtotal. That works until the first real invoice: a discount on one line, another on the whole document, two VAT rates, and a client who wants a PDF that does not look like a screenshot of your app. This tutorial builds the version that survives those cases in Next.js: an invoice editor whose lines behave like a spreadsheet, totals with VAT per rate, and a print view that the browser saves as a clean A4 PDF. The editor is Invoice Line Items from Wingo UI, the library we build, so we are not neutral; the totals and the print sheet are code you can read and copy.

What does a React invoice template need?

It needs one invoice object and two views of it: an editor that changes it and a print sheet that only reads it. Whether you call the result a react invoice generator or a react invoice builder, the object holds the parties, the number, the dates, the currency, the lines and an optional document discount. The totals are never stored next to the lines in the browser state: they are derived from the lines on every render, in the browser for display and again on the server before saving.

ts
// lib/invoice-types.ts
import type { InvoiceDocumentDiscount, InvoiceLine } from "@/components/ui/invoice-line-items";
export type Party = { name: string; address: string[]; vatId?: string };
export type Invoice = {
number: string;
issueDate: Date;
dueDate: Date;
currency: string;
seller: Party;
buyer: Party;
lines: InvoiceLine[];
discount: InvoiceDocumentDiscount | null;
notes?: string;
};

InvoiceLine is { id, quantity, unitPrice, vatRate?, discount?, discountType? } plus the printed fields product, description, unit and vatCategory. The rate is a fraction (0.2 for 20%), and vatCategory tells the 0% rows apart, so a zero rated line and an exempt line print as different rows.

How do I build the invoice line items editor?

Install Invoice Line Items and pass it your products, your VAT rates and your currency. The lines are the part of a React invoice editor that people touch on every invoice, so they get the most keyboard work. The install brings the invoicing lib, the product combobox and the currency input with it:

bash
npx wingo-ui@latest add invoice-line-items

The default VAT options are Romania's (21%, 11% and three 0% categories), because the component was built for e-Factura invoices. Pass your own list through vatRates. The example bills a UK client in pounds, with the three rates GOV.UK lists (opens in a new tab) as of October 2026: 20% standard, 5% reduced and 0% zero rated.

tsx
// app/(admin)/invoices/[id]/lines-editor.tsx
"use client";
import {
InvoiceLineItems,
type InvoiceDocumentDiscount,
type InvoiceLine,
type InvoiceProduct,
} from "@/components/ui/invoice-line-items";
import type { VatRateOption } from "@/lib/invoicing";
export const UK_VAT_RATES: VatRateOption[] = [
{ value: "20", rate: 0.2, category: "S", label: "20%", description: "Standard rate" },
{ value: "5", rate: 0.05, category: "S", label: "5%", description: "Reduced rate" },
{ value: "0-Z", rate: 0, category: "Z", label: "0%", description: "Zero rated" },
];
const PRODUCTS: InvoiceProduct[] = [
{ id: "layout", name: "Editorial design and layout", unit: "project", unitPrice: 2400, vatRate: 0.2 },
{ id: "copyedit", name: "Copyediting", unit: "hr", unitPrice: 45, vatRate: 0.2, keywords: ["proofreading"] },
{ id: "books", name: "Printed books", unit: "pcs", unitPrice: 6.5, vatRate: 0, sku: "BK-200" },
];
type Props = {
lines: InvoiceLine[];
discount: InvoiceDocumentDiscount | null;
onLinesChange: (lines: InvoiceLine[]) => void;
onDiscountChange: (discount: InvoiceDocumentDiscount | null) => void;
};
export function LinesEditor({ lines, discount, onLinesChange, onDiscountChange }: Props) {
return (
<InvoiceLineItems
value={lines}
onValueChange={(next) => onLinesChange(next)}
products={PRODUCTS}
vatRates={UK_VAT_RATES}
defaultVatRate={0.2}
units={["project", "hr", "day", "pcs"]}
documentDiscount={discount}
onDocumentDiscountChange={onDiscountChange}
currency="GBP"
locale="en-GB"
name="invoice[lines]"
/>
);
}

Picking a product fills its unit, price and VAT rate and keeps the quantity. Typing a name that is not in the catalog adds it as free text, with the price flagged until you enter one (allowCustomProducts, on by default). For a large catalog, swap products for onSearchProducts, an async search the combobox debounces.

A React invoice line items table built on react-hook-form's useFieldArray gets you rows you can add and remove, and quantity times price. The keyboard is what makes it feel like a spreadsheet. Tab moves through the cells in reading order, Escape in a number cell restores the value it had, Enter moves to the same cell on the next line, and Cmd or Ctrl + Enter adds a line from anywhere. Alt + Arrow Up or Down moves the current line, and a removed line leaves an undo row for five seconds.

Tab through a line, change the quantity and the VAT rate, then remove a line and press Undo: the VAT rows under the table update on every keystroke
$ npx wingo-ui@latest add invoice-line-items
ProInvoice line items docs

On a phone the same component shows each line as a card, and tapping one edits a copy in a bottom sheet that you keep with Save. The switch follows the component's own width (layout="auto" shows the table from 768px of container width), so a narrow side panel on a desktop gets the cards too. The mobile-first React components guide explains why we measure the container.

How should discounts work on an invoice?

Line discounts come off first, then a document discount is shared across the lines before tax. A line discount, in percent or as an amount, comes off that line before anything else. A document discount comes off the subtotal after the line discounts, and it has to be shared across the lines before tax, in proportion to their value. If you subtract it from the grand total instead, the VAT printed for each rate no longer matches its taxable amount.

computeInvoiceTotals from the invoicing lib does both and rounds the VAT once per rate. Here is the example invoice, with 10% off the design line and 5% off the whole document:

ts
import { computeInvoiceTotals } from "@/lib/invoicing";
const totals = computeInvoiceTotals(
[
{ id: "l1", quantity: 1, unitPrice: 2400, vatRate: 0.2, discount: 10 },
{ id: "l2", quantity: 14, unitPrice: 45, vatRate: 0.2 },
{ id: "l3", quantity: 200, unitPrice: 6.5, vatRate: 0 },
],
{
discount: { type: "percent", value: 5 },
categoryFor: (rate) => (rate > 0 ? "S" : "Z"),
},
);
totals.gross; // 4330
totals.lineDiscount; // 240
totals.documentDiscount; // 204.5
totals.net; // 3885.5
totals.vatBreakdown;
// [{ rate: 0.2, category: "S", net: 2650.5, vat: 530.1, total: 3180.6, lineCount: 2 },
// { rate: 0, category: "Z", net: 1235, vat: 0, total: 1235, lineCount: 1 }]
totals.total; // 4415.6

The 204.50 document discount is split 108.00, 31.50 and 65.00 across the three lines, the last line taking whatever is left so the cents add up. The 20% row is taxed on 2,650.50, which is exactly what the two 20% lines are worth after both discounts.

Why compute VAT per rate instead of per line?

Because rounding each line and adding the results drifts. Three lines that each end in half a penny of VAT round up three times; the same base taxed once rounds up once. EN 16931, the European e-invoicing standard, defines the VAT of each rate as the rate's taxable amount times the rate, rounded to two decimals (rule BR-CO-17 (opens in a new tab)), and the document totals have to add up from those rounded rows. Our post on e-Factura VAT calculation walks through the rule with a worked example and the validation errors it causes.

For the template this means one thing: the per-line VAT in totals.lines is informative, and every total you print, store or send comes from vatBreakdown.

One limit to know if you bill US sales tax: the VAT column formats rates with at most one decimal, so New York City's 8⅞% combined rate (Publication 718 (opens in a new tab), effective March 1, 2025) would read 8.9%. Compute with the exact fraction, and print the rate yourself on the sheet.

How do I make a clean print view for the invoice?

Build a separate sheet for paper and keep the editor off the printout. Inputs, drag handles and hover tints have no place on paper, and an editor printed in dark mode wastes toner. The sheet is plain HTML with fixed paper colors in both themes, which is the one place a Wingo UI screen uses hardcoded colors: the sheet stands for a printed page, so it ignores the theme.

tsx
// app/(admin)/invoices/[id]/invoice-sheet.tsx
import { formatPrice } from "@/lib/commerce";
import { computeInvoiceTotals, formatVatRate } from "@/lib/invoicing";
import type { InvoiceLine } from "@/components/ui/invoice-line-items";
import type { Invoice } from "@/lib/invoice-types";
export function InvoiceSheet({ invoice, locale = "en-GB" }: { invoice: Invoice; locale?: string }) {
const totals = computeInvoiceTotals(invoice.lines, {
discount: invoice.discount,
categoryFor: (rate, line) => (line as InvoiceLine).vatCategory ?? (rate > 0 ? "S" : "E"),
});
const money = (value: number) =>
formatPrice(value, { currency: invoice.currency, locale, fractionDigits: 2 });
const date = (value: Date) => value.toLocaleDateString(locale, { dateStyle: "medium" });
return (
<article className="mx-auto w-[210mm] bg-white p-[16mm] text-[12px] leading-relaxed text-neutral-900 [color-scheme:light] print:w-auto print:p-0">
<header className="flex justify-between gap-8">
<div>
<p className="text-[18px] font-semibold">Invoice {invoice.number}</p>
<p className="text-neutral-500">
Issued {date(invoice.issueDate)}, due {date(invoice.dueDate)}
</p>
</div>
<div className="text-end">
<p className="font-semibold">{invoice.seller.name}</p>
{invoice.seller.address.map((row) => <p key={row}>{row}</p>)}
{invoice.seller.vatId && <p>VAT {invoice.seller.vatId}</p>}
</div>
</header>
<section className="mt-8">
<p className="text-[10px] font-semibold tracking-wider text-neutral-500 uppercase">Bill to</p>
<p className="font-semibold">{invoice.buyer.name}</p>
{invoice.buyer.address.map((row) => <p key={row}>{row}</p>)}
</section>
<table className="mt-8 w-full border-collapse">
<thead className="border-b border-neutral-300 text-start text-neutral-500">
<tr>
<th className="py-2 text-start font-medium">Item</th>
<th className="py-2 text-end font-medium">Qty</th>
<th className="py-2 text-end font-medium">Price</th>
<th className="py-2 text-end font-medium">VAT</th>
<th className="py-2 text-end font-medium">Net</th>
</tr>
</thead>
<tbody>
{invoice.lines.map((line, i) => (
<tr key={line.id} className="border-b border-neutral-200 align-top">
<td className="py-2">
{line.product}
{line.description && <p className="text-neutral-500">{line.description}</p>}
</td>
<td className="py-2 text-end tabular-nums">{line.quantity} {line.unit}</td>
<td className="py-2 text-end tabular-nums">{money(line.unitPrice)}</td>
<td className="py-2 text-end tabular-nums">{formatVatRate(totals.lines[i].vatRate, locale)}</td>
<td className="py-2 text-end tabular-nums">{money(totals.lines[i].net)}</td>
</tr>
))}
</tbody>
</table>
<dl className="ms-auto mt-6 w-64 tabular-nums [&>div]:flex [&>div]:justify-between [&>div]:py-0.5">
<div><dt>Subtotal</dt><dd>{money(totals.gross)}</dd></div>
{totals.discount > 0 && <div><dt>Discounts</dt><dd>-{money(totals.discount)}</dd></div>}
{totals.vatBreakdown.map((row) => (
<div key={`${row.rate}-${row.category}`} className="text-neutral-500">
<dt>VAT {formatVatRate(row.rate, locale)} on {money(row.net)}</dt>
<dd>{money(row.vat)}</dd>
</div>
))}
<div className="mt-1 border-t border-neutral-900 pt-1 font-semibold">
<dt>Total</dt><dd>{money(totals.total)}</dd>
</div>
</dl>
{invoice.notes && <p className="mt-10 text-neutral-500">{invoice.notes}</p>}
</article>
);
}

The net column shows each line after both discounts, so the lines add up to the taxable amounts in the VAT rows. The sheet has borders and no background fills, because browsers leave background colors out of a printout by default.

Then add the page rules to app/globals.css. size on @page is Baseline 2024 according to MDN (opens in a new tab); use letter for clients in North America.

css
@media print {
@page {
size: A4;
margin: 16mm;
}
/* a long invoice repeats its column headers on every page and never splits a line */
thead {
display: table-header-group;
}
tr {
break-inside: avoid;
}
}

Chromium has repeated a thead at the top of each printed page since a June 2016 change that matched Firefox and Edge (the commit (opens in a new tab)), so the display rule only makes the intent explicit. Print a 40-line test invoice in every browser your clients use before a client does.

How do I print only the invoice, not the app?

Mark everything else print:hidden and render the sheet hidden print:block while the editor is on screen. Your admin layout's sidebar and header need print:hidden too, since they sit outside the page component. With that, window.print() and the browser's Save as PDF give a PDF with only the document on it.

react-to-print (opens in a new tab) does the same through an iframe: useReactToPrint({ contentRef }) copies the referenced node and the page's styles into a fresh document and prints that, so you can skip the print:hidden classes. Reach for @react-pdf/renderer only when the server must build PDFs without a person in the loop, such as attachments on scheduled emails; it renders its own layout primitives, so you maintain the invoice layout twice.

What does the final invoice editor look like?

The editor and the sheet share one state object. Totals are never stored, only derived:

tsx
// app/(admin)/invoices/[id]/invoice-editor.tsx
"use client";
import { useState } from "react";
import { Printer } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { Invoice } from "@/lib/invoice-types";
import { LinesEditor } from "./lines-editor";
import { InvoiceSheet } from "./invoice-sheet";
import { saveInvoice } from "./actions";
export function InvoiceEditor({ initial }: { initial: Invoice }) {
const [invoice, setInvoice] = useState(initial);
const [preview, setPreview] = useState(false);
return (
<form action={saveInvoice} className="flex flex-col gap-6">
<input type="hidden" name="invoice[discount]" value={JSON.stringify(invoice.discount)} />
<div className="flex flex-wrap items-center justify-end gap-2 print:hidden">
<Button type="button" variant="ghost" onClick={() => setPreview((on) => !on)}>
{preview ? "Edit" : "Preview"}
</Button>
<Button type="button" variant="outline" leftIcon={<Printer />} onClick={() => window.print()}>
Download PDF
</Button>
<Button type="submit">Save</Button>
</div>
<div className={preview ? "hidden" : "print:hidden"}>
<LinesEditor
lines={invoice.lines}
discount={invoice.discount}
onLinesChange={(lines) => setInvoice((prev) => ({ ...prev, lines }))}
onDiscountChange={(discount) => setInvoice((prev) => ({ ...prev, discount }))}
/>
</div>
<div className={preview ? "overflow-x-auto" : "hidden print:block"}>
<InvoiceSheet invoice={invoice} />
</div>
</form>
);
}

In preview the 210mm sheet scrolls sideways on a phone. If your clients check invoices on phones, measure the container with a ResizeObserver and scale the sheet with a CSS transform instead, which is what the Invoice Page block below does.

How do I save the invoice without trusting the browser's totals?

Send the lines, never the totals, and compute again on the server with the same function. The name prop gives the editor a hidden input holding the lines as JSON, so a native form posts them to a Server Action:

ts
// app/(admin)/invoices/[id]/actions.ts
"use server";
import { computeInvoiceTotals } from "@/lib/invoicing";
import type { InvoiceLine } from "@/components/ui/invoice-line-items";
export async function saveInvoice(form: FormData) {
// validate both with zod in a real app: this is user input
const lines = JSON.parse(String(form.get("invoice[lines]") ?? "[]")) as InvoiceLine[];
const discount = JSON.parse(String(form.get("invoice[discount]") ?? "null"));
// same options as the editor: a line without a rate falls back to defaultVatRate (the lib's default is 0.21)
const { total } = computeInvoiceTotals(lines, { discount, defaultVatRate: 0.2 });
// store lines, discount and total with your data layer; the sheet recomputes the rest from the lines
console.log("invoice total", total);
}

The invoicing lib has no React imports, so it runs in a Server Action, a route handler or a queue worker unchanged.

Can I start from a complete invoice page instead?

Yes, if you bill from Romania. The Invoice Page block wraps the same line editor in a full screen: a client picker that searches by name, CUI or city, series and numbering that skip taken numbers, due date presets of 15, 30 and 45 days, a draft that saves itself 800 ms after the last change (Ctrl or Cmd + S saves at once), an A4 preview scaled to the screen on phones, a send dialog and the e-Factura switch. Its labels are English, but its fields follow Romanian B2B invoices, so for other countries the composition above is the better starting point.

Change a quantity in the lines, then switch the header to Preview: the A4 sheet keeps paper colors in both themes and scales to fit a phone
$ npx wingo-ui@latest add invoice-page
ProInvoice Page docs

For the rest of the back office around it, the Next.js admin dashboard tutorial builds the shell, the data tables and the settings, and more screens built step by step live in the Build it category.

Next step

Install the editor with npx wingo-ui@latest add invoice-line-items, paste the sheet and the print rules, and print a long test invoice in both themes. Invoice Line Items, the invoicing lib and the Invoice Page are part of Wingo UI Pro.

Components in this post

  • Invoice Page

    A React invoice page to edit and view Romanian B2B invoices: client, series, VAT lines, totals, an A4 preview, sending and e-Factura.

    Pro
  • Invoice line items

    A React invoice line items editor that works like a small spreadsheet, with VAT per rate computed the e-Factura way.

    Pro
  • Invoicing

    Invoice math for React billing apps: VAT per rate the way e-Factura checks it, Romanian VAT rates, and CUI, IBAN and CNP validation.

    Pro

FAQ

How do I make a printable invoice in React?

Render the invoice as a plain HTML sheet with fixed white paper colors, hide the editor and the app shell with Tailwind's print:hidden, set the paper size with @page, and call window.print(). The browser's Save as PDF option then produces a PDF that contains only the sheet.

Should I use react-to-print or @react-pdf/renderer for invoices?

Use the browser's print, directly or through react-to-print, when the invoice already exists as HTML on screen and a person prints it. Use @react-pdf/renderer when the server has to generate PDFs on its own, for example to attach them to emails, and accept that its layout primitives are separate from your web components.

How do I apply a discount to the whole invoice when lines have different tax rates?

Share the document discount across the lines in proportion to their value after their own discounts, before any tax, and give the rounding remainder to the last line. Each rate's taxable amount then drops by its share, so the tax per rate stays exact.

Should invoice tax be calculated per line or per rate?

Per rate for VAT invoices: add the taxable amounts of all lines at a rate and round the tax once. EN 16931, the European e-invoicing standard, defines the VAT of each rate that way, and per-line tax can then stay on the document as information.

Is there a free React invoice template?

The print sheet, the print CSS and the rounding rules in this tutorial are yours to copy into any React app. The Wingo UI pieces it imports, Invoice Line Items, the invoicing lib with computeInvoiceTotals and the Invoice Page block, are part of the Pro plan.

  • Invoicing
  • React
  • Next.js
  • Printing
  • VAT

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu founded Wingo UI. He builds its component library, CLI and MCP server, and writes about React interfaces for phones and AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Build it: screens and apps

E-Factura VAT Calculation in TypeScript and React

E-Factura VAT calculation in TypeScript: Romania's 21% and 11% rates, VAT rounded once per rate, the totals ANAF checks to the cent and a React line editor.

SRSerban Rusu·Oct 9, 2026·11 min read
Component guides

IBAN Validation JavaScript: Mod 97, Regex and a React Field

IBAN validation JavaScript that catches typos: mod 97 in TypeScript, why a regex is not enough, a React field that formats as you type and Romanian banks.

SRSerban Rusu·Oct 9, 2026·12 min read
Component guides

Romanian CNP Validator in TypeScript, Zod and React

Build a Romanian CNP validator in TypeScript: the weighted check digit, real birth dates, the new county code 70, a zod rule, a React field and safe storage.

SRSerban Rusu·Oct 9, 2026·13 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components and blocks. Copy the source, make it yours, and let your coding agent build with it.

Product

  • Components
  • Templates
  • Pricing
  • AI agents
  • Theme generator

Resources

  • Docs
  • Blog
  • Changelog
  • Changelog RSS
  • llms.txt

Legal

  • Terms
  • Privacy
  • Refunds
  • License
  • Imprint

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities

© 2026 Wingo UI

Wingo UI