Commerce
Invoice
A printable invoice: parties, line items, tax and totals worked out in any currency, and a small summary card.
- Built on
<table><address><time>Intl.NumberFormatIntl.DateTimeFormat@media printContainer queries- Client JavaScript
- None
- Works without React
- Yes, as HTML and CSS
- Source, gzipped
- 5.0 kB
- Built from
- Badge
Invoice INV-2041
DueHarbor & Pine Coffee
Balance due: $6,750.00
Due
Code
import { Invoice } from "@/components/equip/invoice";
export default function Demo() {
return (
<Invoice
variant="summary"
number="INV-2041"
issued="2026-11-01"
due="2026-12-01"
status="due"
from={{ name: "Fieldwork Design Co.", lines: [] }}
to={{ name: "Harbor & Pine Coffee", lines: [] }}
items={[
{ description: "Brand identity", quantity: 1, unitPrice: 4800 },
{ description: "Packaging design", quantity: 3, unitPrice: 650 },
]}
style={{ width: 224 }}
/>
);
}HTMLworks in any framework, with the part's CSS
<article style="width:224px" class="eq-invoice" data-variant="summary" data-status="due">
<div class="eq-invoice-summary-top">
<h2 class="eq-invoice-title">Invoice <span class="eq-invoice-number">INV-2041</span></h2>
<span class="eq-badge eq-invoice-status" data-tone="neutral" data-variant="soft" data-dot="true">
<span class="eq-badge-dot" aria-hidden="true"></span>
<span class="eq-badge-label">Due</span>
</span>
</div>
<p class="eq-invoice-summary-party">Harbor & Pine Coffee</p>
<p class="eq-invoice-summary-amount"><span class="eq-invoice-hidden">Balance due: </span>$6,750.00</p>
<p class="eq-invoice-summary-due">Due <time dateTime="2026-12-01">Dec 1, 2026</time></p>
</article>Examples
Agency invoice
Tax is worked out from each item's rate when you leave taxes out; the hours line is not taxed.
Invoice
DueFrom
Fieldwork Design Co.2150 Mission Street, Suite 4San Francisco, CA 94110billing@fieldwork.designBill to
Harbor & Pine CoffeeAttn: Dana Whitfield88 Wharf RoadPortland, ME 04101| Description | Qty | Unit price | Tax | Amount |
|---|---|---|---|---|
| Brand identityLogo, wordmark and colour system | 1 | $4,800.00 | 6.25% | $4,800.00 |
| Packaging designThree retail bag sizes | 3 | $650.00 | 6.25% | $1,950.00 |
| Printed swatch book | 2 | $85.00 | 6.25% | $170.00 |
| Design consultationHourly, October | 6.5 | $140.00 | $910.00 |
- Subtotal
- $7,830.00
- Returning client, 5%
- -$391.50
- Sales tax 6.25%
- $410.88
- Total
- $7,849.38
Payment terms. Pay within 30 days by ACH or card. A 1.5% monthly late fee applies after the due date.
Questions about this invoice: billing@fieldwork.design
Code
import { Invoice } from "@/components/equip/invoice";
export default function Example() {
return (
<Invoice
number="INV-2041"
issued="2026-11-01"
due="2026-12-01"
status="due"
brand={
<>
<svg width="28" height="28" viewBox="0 0 28 28" aria-hidden="true" style={{ color: "var(--eq-chart-2)" }}>
<rect x="2" y="2" width="24" height="24" rx="6" fill="currentColor" />
<path d="M9 19V9h9M9 14h7" stroke="var(--eq-raised)" strokeWidth="2.5" strokeLinecap="round" fill="none" />
</svg>
Fieldwork Design Co.
</>
}
from={{ name: "Fieldwork Design Co.", lines: ["2150 Mission Street, Suite 4", "San Francisco, CA 94110", "billing@fieldwork.design"] }}
to={{ name: "Harbor & Pine Coffee", lines: ["Attn: Dana Whitfield", "88 Wharf Road", "Portland, ME 04101"] }}
items={[
{ description: "Brand identity", detail: "Logo, wordmark and colour system", quantity: 1, unitPrice: 4800, taxRate: 0.0625 },
{ description: "Packaging design", detail: "Three retail bag sizes", quantity: 3, unitPrice: 650, taxRate: 0.0625 },
{ description: "Printed swatch book", quantity: 2, unitPrice: 85, taxRate: 0.0625 },
{ description: "Design consultation", detail: "Hourly, October", quantity: 6.5, unitPrice: 140 },
]}
discounts={[{ label: "Returning client, 5%", amount: 391.5 }]}
taxLabel="Sales tax"
notes={
<>
<p>
<strong>Payment terms.</strong> Pay within 30 days by ACH or card. A 1.5% monthly late fee applies after the due date.
</p>
<p>Questions about this invoice: billing@fieldwork.design</p>
</>
}
footer="Thank you for working with us."
/>
);
}HTMLworks in any framework, with the part's CSS
<article class="eq-invoice" data-variant="full" data-status="due">
<header class="eq-invoice-header">
<div class="eq-invoice-brand"><svg width="28" height="28" viewBox="0 0 28 28" aria-hidden="true" style="color:var(--eq-chart-2)"><rect x="2" y="2" width="24" height="24" rx="6" fill="currentColor" /><path d="M9 19V9h9M9 14h7" stroke="var(--eq-raised)" stroke-width="2.5" stroke-linecap="round" fill="none" /></svg>Fieldwork Design Co.</div>
<div class="eq-invoice-heading">
<div class="eq-invoice-title-row">
<h2 class="eq-invoice-title">Invoice</h2>
<span class="eq-badge eq-invoice-status" data-tone="neutral" data-variant="soft" data-dot="true">
<span class="eq-badge-dot" aria-hidden="true"></span>
<span class="eq-badge-label">Due</span>
</span>
</div>
<dl class="eq-invoice-meta">
<div>
<dt>Invoice number</dt>
<dd class="eq-invoice-number">INV-2041</dd>
</div>
<div>
<dt>Issued</dt>
<dd>
<time dateTime="2026-11-01">Nov 1, 2026</time>
</dd>
</div>
<div>
<dt>Due</dt>
<dd>
<time dateTime="2026-12-01">Dec 1, 2026</time>
</dd>
</div>
</dl>
</div>
</header>
<div class="eq-invoice-parties">
<div class="eq-invoice-party">
<p class="eq-invoice-party-label">From</p>
<address class="eq-invoice-address">
<strong>Fieldwork Design Co.</strong>
<span>2150 Mission Street, Suite 4</span>
<span>San Francisco, CA 94110</span>
<span>billing@fieldwork.design</span>
</address>
</div>
<div class="eq-invoice-party">
<p class="eq-invoice-party-label">Bill to</p>
<address class="eq-invoice-address">
<strong>Harbor & Pine Coffee</strong>
<span>Attn: Dana Whitfield</span>
<span>88 Wharf Road</span>
<span>Portland, ME 04101</span>
</address>
</div>
</div>
<table class="eq-invoice-items">
<caption class="eq-invoice-hidden">Line items</caption>
<thead class="eq-invoice-head">
<tr>
<th scope="col">Description</th>
<th scope="col" data-num="">Qty</th>
<th scope="col" data-num="">Unit price</th>
<th scope="col" data-num="">Tax</th>
<th scope="col" data-num="">Amount</th>
</tr>
</thead>
<tbody>
<tr class="eq-invoice-item">
<th scope="row" class="eq-invoice-description">
<span class="eq-invoice-item-name">Brand identity</span>
<span class="eq-invoice-item-detail">Logo, wordmark and colour system</span>
</th>
<td class="eq-invoice-qty" data-num="">1</td>
<td class="eq-invoice-unit" data-num="">$4,800.00</td>
<td class="eq-invoice-tax" data-num="">6.25%</td>
<td class="eq-invoice-amount" data-num="">$4,800.00</td>
</tr>
<tr class="eq-invoice-item">
<th scope="row" class="eq-invoice-description">
<span class="eq-invoice-item-name">Packaging design</span>
<span class="eq-invoice-item-detail">Three retail bag sizes</span>
</th>
<td class="eq-invoice-qty" data-num="">3</td>
<td class="eq-invoice-unit" data-num="">$650.00</td>
<td class="eq-invoice-tax" data-num="">6.25%</td>
<td class="eq-invoice-amount" data-num="">$1,950.00</td>
</tr>
<tr class="eq-invoice-item">
<th scope="row" class="eq-invoice-description">
<span class="eq-invoice-item-name">Printed swatch book</span>
</th>
<td class="eq-invoice-qty" data-num="">2</td>
<td class="eq-invoice-unit" data-num="">$85.00</td>
<td class="eq-invoice-tax" data-num="">6.25%</td>
<td class="eq-invoice-amount" data-num="">$170.00</td>
</tr>
<tr class="eq-invoice-item">
<th scope="row" class="eq-invoice-description">
<span class="eq-invoice-item-name">Design consultation</span>
<span class="eq-invoice-item-detail">Hourly, October</span>
</th>
<td class="eq-invoice-qty" data-num="">6.5</td>
<td class="eq-invoice-unit" data-num="">$140.00</td>
<td class="eq-invoice-tax" data-num=""></td>
<td class="eq-invoice-amount" data-num="">$910.00</td>
</tr>
</tbody>
</table>
<dl class="eq-invoice-totals">
<div class="eq-invoice-total" data-kind="subtotal">
<dt>Subtotal</dt>
<dd>$7,830.00</dd>
</div>
<div class="eq-invoice-total" data-kind="discount">
<dt>Returning client, 5%</dt>
<dd>-$391.50</dd>
</div>
<div class="eq-invoice-total" data-kind="tax">
<dt>Sales tax 6.25%</dt>
<dd>$410.88</dd>
</div>
<div class="eq-invoice-total" data-kind="total" data-final="true">
<dt>Total</dt>
<dd>$7,849.38</dd>
</div>
</dl>
<div class="eq-invoice-notes">
<p><strong>Payment terms.</strong> Pay within 30 days by ACH or card. A 1.5% monthly late fee applies after the due date.</p>
<p>Questions about this invoice: billing@fieldwork.design</p>
</div>
<footer class="eq-invoice-footer">Thank you for working with us.</footer>
</article>German invoice in euros
Currency, digits and dates follow de-DE through Intl; labels and taxLabel set the German words.
Rechnung
OffenVon
Studio Brandt GmbHTorstraße 14010119 BerlinUSt-IdNr. DE298374651Rechnung an
Kaffeerösterei Albers KGHafenweg 1220457 Hamburg| Beschreibung | Menge | Einzelpreis | MwSt. | Betrag |
|---|---|---|---|---|
| Webdesign Startseite | 1 | 2.400,00 € | 19 % | 2.400,00 € |
| FotoproduktionHalber Tag, inklusive Bearbeitung | 1 | 950,00 € | 19 % | 950,00 € |
| Hosting12 Monate | 12 | 18,50 € | 19 % | 222,00 € |
- Zwischensumme
- 3.572,00 €
- MwSt. 19 %
- 678,68 €
- Gesamtbetrag
- 4.250,68 €
Zahlbar innerhalb von 14 Tagen ohne Abzug.
Studio Brandt GmbH, IBAN DE89 3704 0044 0532 0130 00, BIC COBADEFFXXX
Code
import { Invoice } from "@/components/equip/invoice";
export default function Example() {
return (
<Invoice
locale="de-DE"
currency="EUR"
number="RE-2026-0187"
issued="2026-11-10"
due="2026-11-24"
status="due"
from={{ name: "Studio Brandt GmbH", lines: ["Torstraße 140", "10119 Berlin", "USt-IdNr. DE298374651"] }}
to={{ name: "Kaffeerösterei Albers KG", lines: ["Hafenweg 12", "20457 Hamburg"] }}
items={[
{ description: "Webdesign Startseite", quantity: 1, unitPrice: 2400, taxRate: 0.19 },
{ description: "Fotoproduktion", detail: "Halber Tag, inklusive Bearbeitung", quantity: 1, unitPrice: 950, taxRate: 0.19 },
{ description: "Hosting", detail: "12 Monate", quantity: 12, unitPrice: 18.5, taxRate: 0.19 },
]}
taxLabel="MwSt."
labels={{
title: "Rechnung",
number: "Rechnungsnummer",
issued: "Rechnungsdatum",
due: "Fällig am",
from: "Von",
billTo: "Rechnung an",
items: "Positionen",
description: "Beschreibung",
quantity: "Menge",
unitPrice: "Einzelpreis",
tax: "MwSt.",
amount: "Betrag",
subtotal: "Zwischensumme",
total: "Gesamtbetrag",
paid: "Bereits bezahlt",
balance: "Offener Betrag",
status: { due: "Offen", paid: "Bezahlt", overdue: "Überfällig", draft: "Entwurf" },
}}
notes={
<>
<p>Zahlbar innerhalb von 14 Tagen ohne Abzug.</p>
<p>
Studio Brandt GmbH, IBAN <span style={{ fontFamily: "var(--eq-font-mono)" }}>DE89 3704 0044 0532 0130 00</span>, BIC COBADEFFXXX
</p>
</>
}
footer="Amtsgericht Charlottenburg HRB 214587 B, Geschäftsführerin Jana Brandt"
/>
);
}HTMLworks in any framework, with the part's CSS
<article class="eq-invoice" data-variant="full" data-status="due">
<header class="eq-invoice-header">
<div class="eq-invoice-brand">Studio Brandt GmbH</div>
<div class="eq-invoice-heading">
<div class="eq-invoice-title-row">
<h2 class="eq-invoice-title">Rechnung</h2>
<span class="eq-badge eq-invoice-status" data-tone="neutral" data-variant="soft" data-dot="true">
<span class="eq-badge-dot" aria-hidden="true"></span>
<span class="eq-badge-label">Offen</span>
</span>
</div>
<dl class="eq-invoice-meta">
<div>
<dt>Rechnungsnummer</dt>
<dd class="eq-invoice-number">RE-2026-0187</dd>
</div>
<div>
<dt>Rechnungsdatum</dt>
<dd>
<time dateTime="2026-11-10">10.11.2026</time>
</dd>
</div>
<div>
<dt>Fällig am</dt>
<dd>
<time dateTime="2026-11-24">24.11.2026</time>
</dd>
</div>
</dl>
</div>
</header>
<div class="eq-invoice-parties">
<div class="eq-invoice-party">
<p class="eq-invoice-party-label">Von</p>
<address class="eq-invoice-address">
<strong>Studio Brandt GmbH</strong>
<span>Torstraße 140</span>
<span>10119 Berlin</span>
<span>USt-IdNr. DE298374651</span>
</address>
</div>
<div class="eq-invoice-party">
<p class="eq-invoice-party-label">Rechnung an</p>
<address class="eq-invoice-address">
<strong>Kaffeerösterei Albers KG</strong>
<span>Hafenweg 12</span>
<span>20457 Hamburg</span>
</address>
</div>
</div>
<table class="eq-invoice-items">
<caption class="eq-invoice-hidden">Positionen</caption>
<thead class="eq-invoice-head">
<tr>
<th scope="col">Beschreibung</th>
<th scope="col" data-num="">Menge</th>
<th scope="col" data-num="">Einzelpreis</th>
<th scope="col" data-num="">MwSt.</th>
<th scope="col" data-num="">Betrag</th>
</tr>
</thead>
<tbody>
<tr class="eq-invoice-item">
<th scope="row" class="eq-invoice-description">
<span class="eq-invoice-item-name">Webdesign Startseite</span>
</th>
<td class="eq-invoice-qty" data-num="">1</td>
<td class="eq-invoice-unit" data-num="">2.400,00 €</td>
<td class="eq-invoice-tax" data-num="">19 %</td>
<td class="eq-invoice-amount" data-num="">2.400,00 €</td>
</tr>
<tr class="eq-invoice-item">
<th scope="row" class="eq-invoice-description">
<span class="eq-invoice-item-name">Fotoproduktion</span>
<span class="eq-invoice-item-detail">Halber Tag, inklusive Bearbeitung</span>
</th>
<td class="eq-invoice-qty" data-num="">1</td>
<td class="eq-invoice-unit" data-num="">950,00 €</td>
<td class="eq-invoice-tax" data-num="">19 %</td>
<td class="eq-invoice-amount" data-num="">950,00 €</td>
</tr>
<tr class="eq-invoice-item">
<th scope="row" class="eq-invoice-description">
<span class="eq-invoice-item-name">Hosting</span>
<span class="eq-invoice-item-detail">12 Monate</span>
</th>
<td class="eq-invoice-qty" data-num="">12</td>
<td class="eq-invoice-unit" data-num="">18,50 €</td>
<td class="eq-invoice-tax" data-num="">19 %</td>
<td class="eq-invoice-amount" data-num="">222,00 €</td>
</tr>
</tbody>
</table>
<dl class="eq-invoice-totals">
<div class="eq-invoice-total" data-kind="subtotal">
<dt>Zwischensumme</dt>
<dd>3.572,00 €</dd>
</div>
<div class="eq-invoice-total" data-kind="tax">
<dt>MwSt. 19 %</dt>
<dd>678,68 €</dd>
</div>
<div class="eq-invoice-total" data-kind="total" data-final="true">
<dt>Gesamtbetrag</dt>
<dd>4.250,68 €</dd>
</div>
</dl>
<div class="eq-invoice-notes">
<p>Zahlbar innerhalb von 14 Tagen ohne Abzug.</p>
<p>Studio Brandt GmbH, IBAN <span style="font-family:var(--eq-font-mono)">DE89 3704 0044 0532 0130 00</span>, BIC COBADEFFXXX</p>
</div>
<footer class="eq-invoice-footer">Amtsgericht Charlottenburg HRB 214587 B, Geschäftsführerin Jana Brandt</footer>
</article>Overdue, partly paid, with actions
The actions slot is marked data-print="hide", so the printed copy carries the invoice and nothing else.
Invoice
OverdueFrom
Lumen Cloud Services410 King Street WestToronto, ON M5V 1K2Bill to
Harbour Studio Ltd14 Bermondsey StreetLondon SE1 2ESUnited Kingdom| Description | Qty | Unit price | Amount |
|---|---|---|---|
| Team plan12 seats, September | 12 | $24.00 | $288.00 |
| Extra storage500 GB | 1 | $40.00 | $40.00 |
| Priority support | 1 | $99.00 | $99.00 |
- Subtotal
- $427.00
- Total
- $427.00
- Amount paid
- -$200.00
- Balance due
- $227.00
A first payment of $200.00 was received on 2 October. The balance is now 29 days overdue.
Code
"use client";
import { Invoice } from "@/components/equip/invoice";
import { Button } from "@/components/equip/button";
export default function Example() {
return (
<Invoice
number="INV-1988"
issued="2026-09-15"
due="2026-10-15"
status="overdue"
from={{ name: "Lumen Cloud Services", lines: ["410 King Street West", "Toronto, ON M5V 1K2"] }}
to={{ name: "Harbour Studio Ltd", lines: ["14 Bermondsey Street", "London SE1 2ES", "United Kingdom"] }}
items={[
{ description: "Team plan", detail: "12 seats, September", quantity: 12, unitPrice: 24 },
{ description: "Extra storage", detail: "500 GB", quantity: 1, unitPrice: 40 },
{ description: "Priority support", quantity: 1, unitPrice: 99 },
]}
paid={200}
actions={
<>
<Button variant="outline" size="s" onClick={() => window.print()}>
Print
</Button>
<Button variant="accent" size="s">
Pay balance
</Button>
</>
}
notes={<p>A first payment of $200.00 was received on 2 October. The balance is now 29 days overdue.</p>}
/>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
number | string | none | The invoice number. |
issued | string | none | ISO date of issue. |
due | string | none | ISO due date. |
status | "draft" | "due" | "paid" | "overdue" | none | Shown as a badge with its word. |
from | { name: string; lines: string[] } | none | The seller. |
to | { name: string; lines: string[] } | none | The customer billed. |
items | { description: ReactNode; detail?: ReactNode; quantity: number; unitPrice: number; taxRate?: number }[] | none | Line items. unitPrice in major units, taxRate as a fraction such as 0.19. |
discounts | { label: ReactNode; amount: number }[] | none | Taken off the subtotal before tax. |
taxes | { label: ReactNode; amount: number }[] | none | Your own tax lines. Leave out to total each item's taxRate. |
taxLabel | string | none | Word before the rate on computed tax lines, such as VAT. |
paid | number | none | Already received; adds Amount paid and Balance due rows. |
notes | ReactNode | none | Payment terms and bank details. |
footer | ReactNode | none | Small print at the bottom. |
brand | ReactNode | none | Logo or wordmark. Defaults to the seller's name. |
actions | ReactNode | none | Buttons such as Print. Hidden in print. |
currency | string | "USD" | ISO 4217 code. |
locale | string | "en-US" | Formats money, quantities, rates and dates. |
variant | "full" | "summary" | "full" | summary is a small card with the number, status, balance and due date. |
headingLevel | 2 | 3 | 4 | 2 | Level of the Invoice heading. |
labels | Partial<InvoiceLabels> | none | Replaces the built-in words, for another language. |
...rest | ComponentProps<"article"> | none | Every native article attribute, applied to the root. |
Anatomy
| root | .eq-invoice | data-variant and data-status are set here; it is also the container the layout queries. |
|---|---|---|
| header | .eq-invoice-header | |
| brand | .eq-invoice-brand | |
| heading | .eq-invoice-heading | |
| title row | .eq-invoice-title-row | |
| title | .eq-invoice-title | |
| status | .eq-invoice-status | A Badge. |
| meta | .eq-invoice-meta | Number, issue date and due date. |
| number | .eq-invoice-number | |
| actions | .eq-invoice-actions | |
| parties | .eq-invoice-parties | |
| party | .eq-invoice-party | |
| party label | .eq-invoice-party-label | |
| address | .eq-invoice-address | |
| items | .eq-invoice-items | The line items table. |
| head | .eq-invoice-head | |
| item | .eq-invoice-item | |
| description | .eq-invoice-description | The row header cell. |
| item name | .eq-invoice-item-name | |
| item detail | .eq-invoice-item-detail | |
| quantity | .eq-invoice-qty | |
| unit price | .eq-invoice-unit | |
| tax | .eq-invoice-tax | |
| amount | .eq-invoice-amount | |
| totals | .eq-invoice-totals | |
| total | .eq-invoice-total | data-kind is subtotal, discount, tax, total, paid or balance. |
| notes | .eq-invoice-notes | |
| footer | .eq-invoice-footer | |
| summary top | .eq-invoice-summary-top | |
| summary party | .eq-invoice-summary-party | |
| summary amount | .eq-invoice-summary-amount | |
| summary due | .eq-invoice-summary-due | |
| hidden | .eq-invoice-hidden | The table caption and other text for assistive tech only. |
Notes
- Every amount is rounded to the currency's minor units and printed with the same number of decimals, so columns line up.
- Computed tax charges each rate on its share of the subtotal after discounts; pass taxes yourself when your rules differ.
- Below 30rem of container width each line item becomes a block reading quantity times unit price, then the amount.
- When printed it drops its border and background, keeps rows and totals whole across pages and hides anything marked data-print="hide".
- Dates are formatted in UTC, so the server and the browser print the same day.
- Renders on the server with no client JavaScript; a Print button that calls window.print belongs in a client component.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/invoice.tsx323 lines
import type { ComponentProps, ReactNode } from "react";
import { Badge } from "./badge";
import "./invoice.css";
export type InvoiceParty = {
name: string;
/** Address and contact lines, one per line. */
lines: string[];
};
export type InvoiceItem = {
description: ReactNode;
/** A second, quieter line, such as the period or the hours. */
detail?: ReactNode;
quantity: number;
/** In major units of currency. */
unitPrice: number;
/** As a fraction: 0.19 for 19%. */
taxRate?: number;
};
export type InvoiceAdjustment = { label: ReactNode; amount: number };
export type InvoiceStatus = "draft" | "due" | "paid" | "overdue";
export type InvoiceLabels = {
title: string;
number: string;
issued: string;
due: string;
from: string;
billTo: string;
items: string;
description: string;
quantity: string;
unitPrice: string;
tax: string;
amount: string;
subtotal: string;
total: string;
paid: string;
balance: string;
status: Record<InvoiceStatus, string>;
};
const DEFAULT_LABELS: InvoiceLabels = {
title: "Invoice",
number: "Invoice number",
issued: "Issued",
due: "Due",
from: "From",
billTo: "Bill to",
items: "Line items",
description: "Description",
quantity: "Qty",
unitPrice: "Unit price",
tax: "Tax",
amount: "Amount",
subtotal: "Subtotal",
total: "Total",
paid: "Amount paid",
balance: "Balance due",
status: { draft: "Draft", due: "Due", paid: "Paid", overdue: "Overdue" },
};
const TONES = { draft: "neutral", due: "neutral", paid: "ok", overdue: "danger" } as const;
export type InvoiceProps = Omit<ComponentProps<"article">, "children"> & {
number: string;
/** ISO date the invoice was issued. */
issued: string;
/** ISO date payment is due. */
due: string;
status: InvoiceStatus;
from: InvoiceParty;
to: InvoiceParty;
items: InvoiceItem[];
/** Taken off the subtotal before tax. Give positive amounts. */
discounts?: InvoiceAdjustment[];
/** Tax lines as you computed them. Leave out to total each item's taxRate. */
taxes?: InvoiceAdjustment[];
/** Word before the rate on computed tax lines, such as "VAT" or "Sales tax". */
taxLabel?: string;
/** Already received, taken off the total to give the balance due. */
paid?: number;
/** Payment terms and bank details. */
notes?: ReactNode;
footer?: ReactNode;
/** Logo or wordmark. Defaults to the seller's name. */
brand?: ReactNode;
/** Buttons such as Print or Download. Hidden when printed. */
actions?: ReactNode;
/** ISO 4217 code. */
currency?: string;
locale?: string;
/** "summary" is a small card with the number, status, balance and due date. */
variant?: "full" | "summary";
headingLevel?: 2 | 3 | 4;
labels?: Partial<Omit<InvoiceLabels, "status">> & { status?: Partial<Record<InvoiceStatus, string>> };
};
/** A bare date is read as midnight UTC, and every date is shown in UTC, so server and browser agree. */
function toDate(iso: string) {
return new Date(/^\d{4}-\d{2}-\d{2}$/.test(iso) ? `${iso}T00:00:00Z` : iso);
}
export function Invoice({
number,
issued,
due,
status,
from,
to,
items,
discounts = [],
taxes,
taxLabel,
paid,
notes,
footer,
brand,
actions,
currency = "USD",
locale = "en-US",
variant = "full",
headingLevel = 2,
labels: overrides,
className,
...rest
}: InvoiceProps) {
const labels: InvoiceLabels = { ...DEFAULT_LABELS, ...overrides, status: { ...DEFAULT_LABELS.status, ...overrides?.status } };
const money = new Intl.NumberFormat(locale, { style: "currency", currency });
const digits = money.resolvedOptions().maximumFractionDigits ?? 2;
const round = (n: number) => Math.round((n + Number.EPSILON) * 10 ** digits) / 10 ** digits;
const qty = new Intl.NumberFormat(locale, { maximumFractionDigits: 3 });
const percent = new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 3 });
const date = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeZone: "UTC" });
const lines = items.map((item) => round(item.quantity * item.unitPrice));
const subtotal = round(lines.reduce((sum, n) => sum + n, 0));
const discountTotal = round(discounts.reduce((sum, d) => sum + d.amount, 0));
// Without explicit taxes, each rate is charged on its share of the discounted subtotal.
const share = subtotal > 0 ? (subtotal - discountTotal) / subtotal : 1;
const rates = [...new Set(items.flatMap((item) => (item.taxRate != null && item.taxRate > 0 ? [item.taxRate] : [])))];
const taxLines: InvoiceAdjustment[] =
taxes ??
rates.map((rate) => ({
label: `${taxLabel ?? labels.tax} ${percent.format(rate)}`,
amount: round(items.reduce((sum, item, i) => (item.taxRate === rate ? sum + lines[i] : sum), 0) * share * rate),
}));
const total = round(subtotal - discountTotal + taxLines.reduce((sum, t) => sum + t.amount, 0));
const balance = round(total - (paid ?? 0));
const showTax = items.some((item) => item.taxRate != null);
const Heading = `h${headingLevel}` as const;
const badge = (
<Badge className="eq-invoice-status" tone={TONES[status]} dot>
{labels.status[status]}
</Badge>
);
const rootClass = ["eq-invoice", className].filter(Boolean).join(" ");
if (variant === "summary") {
return (
<article {...rest} className={rootClass} data-variant="summary" data-status={status}>
<div className="eq-invoice-summary-top">
<Heading className="eq-invoice-title">
{labels.title} <span className="eq-invoice-number">{number}</span>
</Heading>
{badge}
</div>
<p className="eq-invoice-summary-party">{to.name}</p>
<p className="eq-invoice-summary-amount">
<span className="eq-invoice-hidden">{status === "paid" ? labels.total : labels.balance}: </span>
{money.format(status === "paid" ? total : balance)}
</p>
<p className="eq-invoice-summary-due">
{labels.due} <time dateTime={due}>{date.format(toDate(due))}</time>
</p>
</article>
);
}
return (
<article {...rest} className={rootClass} data-variant="full" data-status={status}>
<header className="eq-invoice-header">
<div className="eq-invoice-brand">{brand ?? from.name}</div>
<div className="eq-invoice-heading">
<div className="eq-invoice-title-row">
<Heading className="eq-invoice-title">{labels.title}</Heading>
{badge}
</div>
<dl className="eq-invoice-meta">
<div>
<dt>{labels.number}</dt>
<dd className="eq-invoice-number">{number}</dd>
</div>
<div>
<dt>{labels.issued}</dt>
<dd>
<time dateTime={issued}>{date.format(toDate(issued))}</time>
</dd>
</div>
<div>
<dt>{labels.due}</dt>
<dd>
<time dateTime={due}>{date.format(toDate(due))}</time>
</dd>
</div>
</dl>
</div>
{actions != null && (
<div className="eq-invoice-actions" data-print="hide">
{actions}
</div>
)}
</header>
<div className="eq-invoice-parties">
{[
{ label: labels.from, party: from },
{ label: labels.billTo, party: to },
].map(({ label, party }) => (
<div key={label} className="eq-invoice-party">
<p className="eq-invoice-party-label">{label}</p>
<address className="eq-invoice-address">
<strong>{party.name}</strong>
{party.lines.map((line, i) => (
<span key={i}>{line}</span>
))}
</address>
</div>
))}
</div>
<table className="eq-invoice-items">
<caption className="eq-invoice-hidden">{labels.items}</caption>
<thead className="eq-invoice-head">
<tr>
<th scope="col">{labels.description}</th>
<th scope="col" data-num="">
{labels.quantity}
</th>
<th scope="col" data-num="">
{labels.unitPrice}
</th>
{showTax && (
<th scope="col" data-num="">
{labels.tax}
</th>
)}
<th scope="col" data-num="">
{labels.amount}
</th>
</tr>
</thead>
<tbody>
{items.map((item, i) => (
<tr key={i} className="eq-invoice-item">
<th scope="row" className="eq-invoice-description">
<span className="eq-invoice-item-name">{item.description}</span>
{item.detail != null && <span className="eq-invoice-item-detail">{item.detail}</span>}
</th>
<td className="eq-invoice-qty" data-num="">
{qty.format(item.quantity)}
</td>
<td className="eq-invoice-unit" data-num="">
{money.format(item.unitPrice)}
</td>
{showTax && (
<td className="eq-invoice-tax" data-num="">
{item.taxRate != null ? percent.format(item.taxRate) : ""}
</td>
)}
<td className="eq-invoice-amount" data-num="">
{money.format(lines[i])}
</td>
</tr>
))}
</tbody>
</table>
<dl className="eq-invoice-totals">
<div className="eq-invoice-total" data-kind="subtotal">
<dt>{labels.subtotal}</dt>
<dd>{money.format(subtotal)}</dd>
</div>
{discounts.map((d, i) => (
<div key={`d${i}`} className="eq-invoice-total" data-kind="discount">
<dt>{d.label}</dt>
<dd>{money.format(0 - d.amount)}</dd>
</div>
))}
{taxLines.map((t, i) => (
<div key={`t${i}`} className="eq-invoice-total" data-kind="tax">
<dt>{t.label}</dt>
<dd>{money.format(t.amount)}</dd>
</div>
))}
<div className="eq-invoice-total" data-kind="total" data-final={paid == null || undefined}>
<dt>{labels.total}</dt>
<dd>{money.format(total)}</dd>
</div>
{paid != null && (
<>
<div className="eq-invoice-total" data-kind="paid">
<dt>{labels.paid}</dt>
<dd>{money.format(0 - paid)}</dd>
</div>
<div className="eq-invoice-total" data-kind="balance" data-final="">
<dt>{labels.balance}</dt>
<dd>{money.format(balance)}</dd>
</div>
</>
)}
</dl>
{notes != null && <div className="eq-invoice-notes">{notes}</div>}
{footer != null && <footer className="eq-invoice-footer">{footer}</footer>}
</article>
);
}components/equip/invoice.css412 lines
@layer equip {
/* A document on the page: a bordered sheet, no shadow, since it does not float. */
.eq-invoice {
container-type: inline-size;
box-sizing: border-box;
inline-size: 100%;
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--eq-space-5);
padding: var(--eq-space-6);
border: 1px solid var(--eq-line);
border-radius: var(--eq-radius-l);
background: var(--eq-raised);
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-s) / 1.45 var(--eq-font);
}
.eq-invoice :is(p, dl, dd, address, h2, h3, h4) {
margin: 0;
}
.eq-invoice-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.eq-invoice-number {
font-family: var(--eq-font-mono);
font-variation-settings: var(--eq-font-mono-settings);
font-variant-numeric: tabular-nums;
}
/* ---- header ---- */
.eq-invoice-header {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: var(--eq-space-4) var(--eq-space-5);
}
.eq-invoice-brand {
display: flex;
align-items: center;
gap: var(--eq-space-2);
min-width: 0;
font: var(--eq-weight-strong) var(--eq-text-l) / 1.3 var(--eq-font);
}
.eq-invoice-heading {
display: grid;
grid-template-columns: minmax(0, auto);
gap: var(--eq-space-2);
min-width: 0;
}
.eq-invoice-title-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--eq-space-2);
}
.eq-invoice-title {
font: var(--eq-weight-strong) calc(var(--eq-text-l) * 1.375) / 1.2 var(--eq-font);
letter-spacing: -0.015em;
}
.eq-invoice-meta {
display: grid;
grid-template-columns: auto auto;
gap: 2px var(--eq-space-4);
}
.eq-invoice-meta > div {
display: contents;
}
/* Long labels and numbers (a German "Rechnungsnummer") break rather than widen the sheet. */
.eq-invoice-meta :is(dt, dd) {
min-width: 0;
overflow-wrap: anywhere;
}
.eq-invoice-meta dt {
color: var(--eq-ink-2);
}
.eq-invoice-meta dd {
font-variant-numeric: tabular-nums;
text-align: end;
}
.eq-invoice-actions {
display: flex;
flex-wrap: wrap;
gap: var(--eq-space-2);
flex-basis: 100%;
justify-content: flex-end;
}
/* ---- parties ---- */
.eq-invoice-parties {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr));
gap: var(--eq-space-4) var(--eq-space-5);
}
.eq-invoice-party {
display: grid;
gap: var(--eq-space-1);
min-width: 0;
}
.eq-invoice-party-label {
color: var(--eq-ink-2);
font-size: var(--eq-text-xs);
font-weight: var(--eq-weight-strong);
}
.eq-invoice-address {
display: grid;
font-style: normal;
overflow-wrap: anywhere;
}
.eq-invoice-address strong {
font-weight: var(--eq-weight-strong);
}
/* ---- line items ---- */
.eq-invoice-items {
width: 100%;
border-collapse: collapse;
font-variant-numeric: tabular-nums;
}
.eq-invoice-items :is(th, td) {
padding: var(--eq-space-2) 0 var(--eq-space-2) var(--eq-space-4);
text-align: start;
vertical-align: top;
}
.eq-invoice-items :is(th, td):first-child {
padding-inline-start: 0;
}
.eq-invoice-items [data-num] {
text-align: end;
white-space: nowrap;
}
.eq-invoice-head th {
border-bottom: 1px solid var(--eq-line-strong);
color: var(--eq-ink-2);
font-size: var(--eq-text-xs);
font-weight: var(--eq-weight-strong);
}
.eq-invoice-item {
border-bottom: 1px solid var(--eq-line);
}
.eq-invoice-description {
font-weight: var(--eq-weight);
}
.eq-invoice-item-name {
display: block;
font-weight: var(--eq-weight-strong);
}
.eq-invoice-item-detail {
display: block;
color: var(--eq-ink-2);
}
.eq-invoice-tax {
color: var(--eq-ink-2);
}
/* ---- totals ---- */
.eq-invoice-totals {
display: grid;
gap: var(--eq-space-1);
width: min(100%, 20rem);
margin-inline-start: auto;
font-variant-numeric: tabular-nums;
}
/* On a narrow sheet the amount drops under its label, still at the end. */
.eq-invoice-total {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
column-gap: var(--eq-space-4);
}
.eq-invoice-total dt {
color: var(--eq-ink-2);
}
.eq-invoice-total dd {
margin-inline-start: auto;
text-align: end;
white-space: nowrap;
}
.eq-invoice-total[data-kind="total"] {
margin-top: var(--eq-space-1);
padding-top: var(--eq-space-2);
border-top: 1px solid var(--eq-line-strong);
}
.eq-invoice-total:is([data-kind="total"], [data-final]) :is(dt, dd) {
color: var(--eq-ink);
font-weight: var(--eq-weight-strong);
}
.eq-invoice-total[data-final] {
font-size: var(--eq-text-l);
}
.eq-invoice-total[data-kind="balance"] {
margin-top: var(--eq-space-1);
padding-top: var(--eq-space-2);
border-top: 1px solid var(--eq-line);
}
/* ---- notes and footer ---- */
.eq-invoice-notes {
display: grid;
gap: var(--eq-space-1);
padding-top: var(--eq-space-4);
border-top: 1px solid var(--eq-line);
color: var(--eq-ink-2);
overflow-wrap: anywhere;
}
.eq-invoice-notes :is(strong, b) {
color: var(--eq-ink);
}
.eq-invoice-footer {
color: var(--eq-ink-3);
font-size: var(--eq-text-xs);
}
/* ---- narrow: each line item becomes a block, description over "qty x unit amount" ---- */
@container (width < 30rem) {
.eq-invoice[data-variant="full"] {
gap: var(--eq-space-4);
}
.eq-invoice-head {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.eq-invoice-items,
.eq-invoice-items tbody {
display: block;
}
.eq-invoice-item {
display: flex;
flex-wrap: wrap;
align-items: baseline;
column-gap: var(--eq-space-1);
padding-block: var(--eq-space-2);
}
.eq-invoice-items .eq-invoice-item > * {
padding: 0;
}
.eq-invoice-description {
flex: 1 0 100%;
margin-bottom: 2px;
}
.eq-invoice-qty,
.eq-invoice-unit,
.eq-invoice-tax {
color: var(--eq-ink-2);
}
.eq-invoice-unit::before {
content: "\00d7 " / "";
}
.eq-invoice-tax:not(:empty)::before {
content: "\00b7 " / "";
}
.eq-invoice-amount {
margin-inline-start: auto;
font-weight: var(--eq-weight-strong);
}
.eq-invoice-totals {
width: 100%;
}
}
@container (width < 22rem) {
.eq-invoice[data-variant="full"] {
padding: var(--eq-space-4);
}
}
/* ---- summary card ---- */
.eq-invoice[data-variant="summary"] {
gap: var(--eq-space-1);
padding: var(--eq-space-3) var(--eq-space-4);
}
.eq-invoice-summary-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--eq-space-2);
min-width: 0;
}
.eq-invoice[data-variant="summary"] .eq-invoice-title {
min-width: 0;
font-size: var(--eq-text-s);
letter-spacing: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.eq-invoice[data-variant="summary"] .eq-invoice-number {
color: var(--eq-ink-2);
font-weight: var(--eq-weight);
}
.eq-invoice-summary-party {
color: var(--eq-ink-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.eq-invoice-summary-amount {
margin-top: var(--eq-space-1);
font: var(--eq-weight-strong) calc(var(--eq-text-l) * 1.5) / 1.15 var(--eq-font);
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
.eq-invoice-summary-due {
color: var(--eq-ink-2);
}
.eq-invoice[data-status="overdue"] .eq-invoice-summary-due {
color: var(--eq-danger);
}
/* ---- print: ink on paper, nothing the reader cannot use ---- */
@media print {
.eq-invoice {
padding: 0;
border: 0;
border-radius: 0;
background: none;
color: var(--eq-ink);
}
.eq-invoice [data-print="hide"] {
display: none;
}
.eq-invoice-head {
display: table-header-group;
}
.eq-invoice-item,
.eq-invoice-parties,
.eq-invoice-totals,
.eq-invoice-notes {
break-inside: avoid;
}
.eq-invoice-status {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
}