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

Due

Harbor & 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 &amp; 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.

Fieldwork Design Co.

Invoice

Due
Invoice number
INV-2041
Issued
Due

From

Fieldwork Design Co.2150 Mission Street, Suite 4San Francisco, CA 94110billing@fieldwork.design

Bill to

Harbor & Pine CoffeeAttn: Dana Whitfield88 Wharf RoadPortland, ME 04101
Line items
DescriptionQtyUnit priceTaxAmount
Brand identityLogo, wordmark and colour system1$4,800.006.25%$4,800.00
Packaging designThree retail bag sizes3$650.006.25%$1,950.00
Printed swatch book2$85.006.25%$170.00
Design consultationHourly, October6.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

Thank you for working with us.
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 &amp; 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.

Studio Brandt GmbH

Rechnung

Offen
Rechnungsnummer
RE-2026-0187
Rechnungsdatum
Fällig am

Von

Studio Brandt GmbHTorstraße 14010119 BerlinUSt-IdNr. DE298374651

Rechnung an

Kaffeerösterei Albers KGHafenweg 1220457 Hamburg
Positionen
BeschreibungMengeEinzelpreisMwSt.Betrag
Webdesign Startseite12.400,00 €19 %2.400,00 €
FotoproduktionHalber Tag, inklusive Bearbeitung1950,00 €19 %950,00 €
Hosting12 Monate1218,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

Amtsgericht Charlottenburg HRB 214587 B, Geschäftsführerin Jana Brandt
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.

Lumen Cloud Services

Invoice

Overdue
Invoice number
INV-1988
Issued
Due

From

Lumen Cloud Services410 King Street WestToronto, ON M5V 1K2

Bill to

Harbour Studio Ltd14 Bermondsey StreetLondon SE1 2ESUnited Kingdom
Line items
DescriptionQtyUnit priceAmount
Team plan12 seats, September12$24.00$288.00
Extra storage500 GB1$40.00$40.00
Priority support1$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

PropTypeDefaultWhat it does
numberstringnoneThe invoice number.
issuedstringnoneISO date of issue.
duestringnoneISO due date.
status"draft" | "due" | "paid" | "overdue"noneShown as a badge with its word.
from{ name: string; lines: string[] }noneThe seller.
to{ name: string; lines: string[] }noneThe customer billed.
items{ description: ReactNode; detail?: ReactNode; quantity: number; unitPrice: number; taxRate?: number }[]noneLine items. unitPrice in major units, taxRate as a fraction such as 0.19.
discounts{ label: ReactNode; amount: number }[]noneTaken off the subtotal before tax.
taxes{ label: ReactNode; amount: number }[]noneYour own tax lines. Leave out to total each item's taxRate.
taxLabelstringnoneWord before the rate on computed tax lines, such as VAT.
paidnumbernoneAlready received; adds Amount paid and Balance due rows.
notesReactNodenonePayment terms and bank details.
footerReactNodenoneSmall print at the bottom.
brandReactNodenoneLogo or wordmark. Defaults to the seller's name.
actionsReactNodenoneButtons such as Print. Hidden in print.
currencystring"USD"ISO 4217 code.
localestring"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.
headingLevel2 | 3 | 42Level of the Invoice heading.
labelsPartial<InvoiceLabels>noneReplaces the built-in words, for another language.
...restComponentProps<"article">noneEvery native article attribute, applied to the root.

Anatomy

root.eq-invoicedata-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-statusA Badge.
meta.eq-invoice-metaNumber, 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-itemsThe line items table.
head.eq-invoice-head
item.eq-invoice-item
description.eq-invoice-descriptionThe 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-totaldata-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-hiddenThe 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;
    }
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index