Invoice Generator - Create simple, professional invoices as PDF.
---
format: typebulb/v1
name: Invoice Generator
---
**code.tsx**
```tsx
import React, { useState, useEffect } from "react";
import { createRoot } from "react-dom/client";
import { jsPDF } from "jspdf";
interface LineItem {
description: string;
qty: string;
rate: string;
}
interface Invoice {
number: string;
date: string;
dueDate: string;
currency: string;
fromName: string;
fromAddress: string;
fromEmail: string;
fromTaxId: string;
toName: string;
toAddress: string;
toEmail: string;
items: LineItem[];
taxPercent: string;
taxNote: string;
paymentInfo: string;
notes: string;
}
const today = new Date().toISOString().split("T")[0];
const STORAGE_KEY = "typebulb_invoice";
const blank: Invoice = {
number: "1",
date: today,
dueDate: "Due on Receipt",
currency: "USD",
fromName: "",
fromAddress: "",
fromEmail: "",
fromTaxId: "",
toName: "",
toAddress: "",
toEmail: "",
items: [{ description: "", qty: "", rate: "" }],
taxPercent: "0",
taxNote: "",
paymentInfo: "",
notes: "",
};
const fmtMoney = (n: number) =>
n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const fmtDate = (s: string) => {
const d = new Date(s + "T00:00:00");
if (isNaN(d.getTime())) return s;
return d.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "2-digit" });
};
const calcSubtotal = (items: LineItem[]) =>
items.reduce((sum, it) => sum + (parseFloat(it.qty) || 0) * (parseFloat(it.rate) || 0), 0);
function buildPDF(inv: Invoice) {
const doc = new jsPDF();
const pw = doc.internal.pageSize.getWidth();
const m = 20;
const cw = pw - 2 * m;
let y = 25;
const gray = () => doc.setTextColor(100);
const black = () => doc.setTextColor(30);
const bold = () => doc.setFont("helvetica", "bold");
const norm = () => doc.setFont("helvetica", "normal");
const fs = (s: number) => doc.setFontSize(s);
// Title
bold(); fs(22); black();
doc.text("INVOICE", pw / 2, y, { align: "center" });
y += 16;
// Invoice details
fs(9.5);
bold(); gray();
doc.text("Invoice Number:", m, y);
norm(); black();
doc.text(inv.number, m + 33, y);
bold(); gray();
doc.text("Invoice Date:", pw / 2 + 5, y);
norm(); black();
doc.text(fmtDate(inv.date), pw / 2 + 5 + 28, y);
y += 6;
bold(); gray();
doc.text("Due Date:", pw / 2 + 5, y);
norm(); black();
doc.text(inv.dueDate, pw / 2 + 5 + 28, y);
y += 12;
// Divider
doc.setDrawColor(220);
doc.setLineWidth(0.3);
doc.line(m, y, m + cw, y);
y += 10;
// From / Bill To headers
bold(); fs(8); gray();
doc.text("From:", m, y);
doc.text("Bill To:", pw / 2 + 5, y);
y += 6;
// Names
fs(10); bold(); black();
doc.text(inv.fromName, m, y);
doc.text(inv.toName, pw / 2 + 5, y);
y += 5.5;
// Addresses
norm(); fs(9.5);
const fAddr = inv.fromAddress.split("\n").filter(Boolean);
const tAddr = inv.toAddress.split("\n").filter(Boolean);
for (let i = 0; i < Math.max(fAddr.length, tAddr.length); i++) {
if (fAddr[i]) doc.text(fAddr[i], m, y);
if (tAddr[i]) doc.text(tAddr[i], pw / 2 + 5, y);
y += 5;
}
if (inv.fromEmail || inv.toEmail) {
gray();
if (inv.fromEmail) doc.text("Email: " + inv.fromEmail, m, y);
if (inv.toEmail) doc.text("Email: " + inv.toEmail, pw / 2 + 5, y);
y += 5;
}
if (inv.fromTaxId) {
gray();
doc.text("Tax ID: " + inv.fromTaxId, m, y);
y += 5;
}
black();
y += 10;
// Table
const col = [m, m + 90, m + 118, m + 145];
doc.setFillColor(245, 245, 245);
doc.rect(m, y - 1, cw, 9, "F");
bold(); fs(8.5); gray();
doc.text("Description", col[0] + 3, y + 5.5);
doc.text("Qty", col[1] + 3, y + 5.5);
doc.text("Rate (" + inv.currency + ")", col[2] + 3, y + 5.5);
doc.text("Amount (" + inv.currency + ")", col[3] + 3, y + 5.5);
y += 9;
doc.setDrawColor(200);
doc.line(m, y, m + cw, y);
y += 2;
norm(); fs(9.5); black();
inv.items.forEach((it) => {
const q = parseFloat(it.qty) || 0;
const r = parseFloat(it.rate) || 0;
y += 6;
doc.text(it.description, col[0] + 3, y);
doc.text(it.qty || "0", col[1] + 3, y);
doc.text("$" + fmtMoney(r), col[2] + 3, y);
doc.text("$" + fmtMoney(q * r), col[3] + 3, y);
});
y += 4;
doc.setDrawColor(220);
doc.line(m, y, m + cw, y);
y += 10;
// Totals
const sub = calcSubtotal(inv.items);
const taxPct = parseFloat(inv.taxPercent) || 0;
const tax = sub * taxPct / 100;
const total = sub + tax;
const tl = col[2] + 3;
const tv = m + cw - 3;
fs(9.5);
bold(); gray();
doc.text("Subtotal:", tl, y);
norm(); black();
doc.text(inv.currency + " $" + fmtMoney(sub), tv, y, { align: "right" });
y += 6;
bold(); gray();
doc.text("Tax:", tl, y);
norm(); black();
doc.text(inv.currency + " $" + fmtMoney(tax), tv, y, { align: "right" });
if (inv.taxNote) {
y += 4;
gray(); fs(8);
doc.text("(" + inv.taxNote + ")", tv, y, { align: "right" });
fs(9.5); black();
}
y += 7;
doc.setDrawColor(180);
doc.line(tl - 3, y - 2, m + cw, y - 2);
bold(); fs(11); gray();
doc.text("Total Due:", tl, y + 2);
black();
doc.text(inv.currency + " $" + fmtMoney(total), tv, y + 2, { align: "right" });
y += 16;
// Payment Info
if (inv.paymentInfo.trim()) {
fs(9); bold(); gray();
doc.text("Payment Information:", m, y);
y += 6;
norm(); black(); fs(9.5);
const lines = doc.splitTextToSize(inv.paymentInfo, cw);
doc.text(lines, m, y);
y += lines.length * 4.5 + 8;
}
// Notes
if (inv.notes.trim()) {
fs(9); bold(); gray();
doc.text("Notes:", m, y);
y += 6;
norm(); black(); fs(9.5);
const lines = doc.splitTextToSize(inv.notes, cw);
doc.text(lines, m, y);
}
const blob = doc.output("blob");
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "invoice_" + inv.number + ".pdf";
a.click();
URL.revokeObjectURL(url);
}
function loadSaved(): Invoice {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return blank;
return { ...blank, ...JSON.parse(raw) };
} catch {
return blank;
}
}
const App = () => {
const [inv, setInv] = useState<Invoice>(loadSaved);
useEffect(() => {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(inv)); } catch {}
}, [inv]);
const newInvoice = () => {
const nextNum = String((parseInt(inv.number) || 0) + 1);
setInv({ ...inv, number: nextNum, date: today, dueDate: "Due on Receipt", toName: "", toAddress: "", toEmail: "", items: [{ description: "", qty: "", rate: "" }], notes: "" });
};
const set = <K extends keyof Invoice>(k: K, v: Invoice[K]) =>
setInv((p) => ({ ...p, [k]: v }));
const setItem = (i: number, field: keyof LineItem, val: string) => {
const next = [...inv.items];
next[i] = { ...next[i], [field]: val };
set("items", next);
};
const addItem = () => set("items", [...inv.items, { description: "", qty: "", rate: "" }]);
const removeItem = (i: number) => {
if (inv.items.length <= 1) return;
set("items", inv.items.filter((_, idx) => idx !== i));
};
const sub = calcSubtotal(inv.items);
const taxPct = parseFloat(inv.taxPercent) || 0;
const tax = sub * taxPct / 100;
const total = sub + tax;
const ua = navigator.userAgent;
const isWebView = (/iPhone|iPad|iPod/i.test(ua) && !ua.includes("Safari")) || /; wv\)/.test(ua);
if (isWebView) return (
<div className="container">
<div className="in-app-banner">
Your in-app browser can't download files. Please open this page directly in your browser.
<button className="copy-link-btn" onClick={async () => tb.copy(await tb.url())}>
Copy Link
</button>
</div>
</div>
);
return (
<div className="container">
<header className="main-header">
<h1>Invoice Generator</h1>
<p className="subtitle">Create simple, professional invoices. Runs entirely in your browser.</p>
</header>
<div className="form-card">
<div className="row-4">
<div className="field">
<label>Invoice #</label>
<input value={inv.number} onChange={(e) => set("number", e.target.value)} />
</div>
<div className="field">
<label>Date</label>
<input type="date" value={inv.date} onChange={(e) => set("date", e.target.value)} />
</div>
<div className="field">
<label>Due Date</label>
<input value={inv.dueDate} onChange={(e) => set("dueDate", e.target.value)} />
</div>
<div className="field">
<label>Currency</label>
<input value={inv.currency} onChange={(e) => set("currency", e.target.value)} />
</div>
</div>
</div>
<div className="form-row-2">
<div className="form-card">
<h3>From</h3>
<div className="field">
<label>Name</label>
<input value={inv.fromName} onChange={(e) => set("fromName", e.target.value)} placeholder="Your name" />
</div>
<div className="field">
<label>Address</label>
<textarea rows={3} value={inv.fromAddress} onChange={(e) => set("fromAddress", e.target.value)} placeholder="Street address City, State, ZIP" />
</div>
<div className="field">
<label>Email</label>
<input type="email" value={inv.fromEmail} onChange={(e) => set("fromEmail", e.target.value)} placeholder="[email protected]" />
</div>
<div className="field">
<label>Tax ID</label>
<input value={inv.fromTaxId} onChange={(e) => set("fromTaxId", e.target.value)} placeholder="Optional" />
</div>
</div>
<div className="form-card">
<h3>Bill To</h3>
<div className="field">
<label>Name</label>
<input value={inv.toName} onChange={(e) => set("toName", e.target.value)} placeholder="Client name" />
</div>
<div className="field">
<label>Address</label>
<textarea rows={3} value={inv.toAddress} onChange={(e) => set("toAddress", e.target.value)} placeholder="Street address City, State, ZIP" />
</div>
<div className="field">
<label>Email</label>
<input type="email" value={inv.toEmail} onChange={(e) => set("toEmail", e.target.value)} placeholder="[email protected]" />
</div>
</div>
</div>
<div className="form-card">
<h3>Line Items</h3>
<table className="items-table">
<thead>
<tr>
<th className="col-desc">Description</th>
<th className="col-qty">Qty</th>
<th className="col-rate">Rate ({inv.currency})</th>
<th className="col-amount">Amount</th>
<th className="col-action"></th>
</tr>
</thead>
<tbody>
{inv.items.map((item, i) => {
const amt = (parseFloat(item.qty) || 0) * (parseFloat(item.rate) || 0);
return (
<tr key={i}>
<td><input value={item.description} onChange={(e) => setItem(i, "description", e.target.value)} placeholder="Service description" /></td>
<td><input type="number" value={item.qty} onChange={(e) => setItem(i, "qty", e.target.value)} placeholder="0" /></td>
<td><input type="number" value={item.rate} onChange={(e) => setItem(i, "rate", e.target.value)} placeholder="0" step="any" /></td>
<td className="amount-cell">${fmtMoney(amt)}</td>
<td><button className="remove-btn" onClick={() => removeItem(i)} disabled={inv.items.length <= 1}>×</button></td>
</tr>
);
})}
</tbody>
</table>
<button className="add-btn" onClick={addItem}>+ Add Line Item</button>
</div>
<div className="form-card">
<div className="row-tax">
<div className="field">
<label>Tax %</label>
<input type="number" value={inv.taxPercent} onChange={(e) => set("taxPercent", e.target.value)} step="any" />
</div>
<div className="field tax-note-field">
<label>Tax Note</label>
<input value={inv.taxNote} onChange={(e) => set("taxNote", e.target.value)} placeholder="e.g. not applicable — non-resident supplier" />
</div>
</div>
</div>
<div className="form-row-2">
<div className="form-card">
<h3>Payment Information</h3>
<textarea rows={4} value={inv.paymentInfo} onChange={(e) => set("paymentInfo", e.target.value)} placeholder="Bank name, BSB, account number, etc." />
</div>
<div className="form-card">
<h3>Notes</h3>
<textarea rows={4} value={inv.notes} onChange={(e) => set("notes", e.target.value)} placeholder="Additional notes..." />
</div>
</div>
<div className="totals-card">
<div className="total-row">
<span>Subtotal</span>
<span>{inv.currency} ${fmtMoney(sub)}</span>
</div>
<div className="total-row">
<span>Tax ({inv.taxPercent}%)</span>
<span>{inv.currency} ${fmtMoney(tax)}</span>
</div>
<div className="total-row total-due">
<span>Total Due</span>
<span>{inv.currency} ${fmtMoney(total)}</span>
</div>
</div>
<div className="action-row">
<button className="action-btn" onClick={newInvoice}>New Invoice</button>
<button className="action-btn" onClick={() => buildPDF(inv)}>Download PDF</button>
</div>
</div>
);
};
const container = document.getElementById("root");
const root = createRoot(container!);
root.render(<App />);
```
**styles.css**
```css
:root {
--bg: #fafafa;
--text: #1d1d1f;
--text-muted: #6e6e73;
--border: rgba(0, 0, 0, 0.1);
--card-bg: #ffffff;
--input-bg: #f5f5f7;
--button-bg: #1d1d1f;
--button-text: #ffffff;
--radius: 12px;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.08);
--accent: #1d1d1f;
}
html[data-theme="dark"] {
--bg: #1c1c1c;
--text: #f5f5f7;
--text-muted: #a1a1a6;
--border: rgba(255, 255, 255, 0.1);
--card-bg: #232323;
--input-bg: #2a2a2a;
--button-bg: #f5f5f7;
--button-text: #1d1d1f;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.5);
--accent: #f5f5f7;
}
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", "SF Pro Display", sans-serif;
margin: 0;
padding: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
letter-spacing: -0.01em;
line-height: 1.5;
}
.container {
max-width: 820px;
margin: 0 auto;
padding: 3rem 2rem;
}
.main-header {
text-align: center;
margin-bottom: 2.5rem;
}
h1 {
font-size: 2.5rem;
font-weight: 700;
margin: 0 0 0.5rem;
letter-spacing: -0.03em;
line-height: 1.1;
}
.subtitle {
color: var(--text-muted);
font-size: 1rem;
font-weight: 400;
margin: 0;
line-height: 1.6;
}
.form-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
margin-bottom: 1rem;
box-shadow: var(--shadow-sm);
}
.form-card h3 {
margin: 0 0 0.875rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.form-row-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
.row-4 {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 0.75rem;
}
.row-tax {
display: grid;
grid-template-columns: 100px 1fr;
gap: 0.75rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.5rem;
}
.field:last-child {
margin-bottom: 0;
}
.field label {
font-size: 0.6875rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
input, textarea {
background: var(--input-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.625rem 0.75rem;
font-size: 0.875rem;
color: var(--text);
font-family: inherit;
transition: border-color 0.2s;
width: 100%;
box-sizing: border-box;
}
input:focus, textarea:focus {
outline: none;
border-color: var(--accent);
}
textarea {
resize: vertical;
}
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
}
/* Items table */
.items-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 0.75rem;
}
.items-table th {
text-align: left;
font-size: 0.6875rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0 0.25rem 0.5rem;
}
.items-table td {
padding: 0.25rem;
}
.items-table input {
width: 100%;
}
.col-desc { width: 42%; }
.col-qty { width: 12%; }
.col-rate { width: 18%; }
.col-amount { width: 20%; }
.col-action { width: 8%; }
.amount-cell {
font-size: 0.875rem;
font-weight: 600;
padding: 0.625rem 0.75rem !important;
color: var(--text);
white-space: nowrap;
}
.remove-btn {
background: none;
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-muted);
font-size: 1.25rem;
cursor: pointer;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.remove-btn:hover:not(:disabled) {
color: #e55;
border-color: #e55;
}
.remove-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.add-btn {
background: none;
border: 1px dashed var(--border);
border-radius: 8px;
color: var(--text-muted);
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
padding: 0.5rem;
width: 100%;
transition: all 0.2s;
}
.add-btn:hover {
border-color: var(--accent);
color: var(--text);
}
/* Totals */
.totals-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
margin-bottom: 1.5rem;
box-shadow: var(--shadow-sm);
max-width: 100%;
}
.total-row {
display: flex;
justify-content: space-between;
padding: 0.375rem 0;
font-size: 0.9375rem;
}
.total-row span:first-child {
color: var(--text-muted);
font-weight: 500;
}
.total-row span:last-child {
font-weight: 600;
}
.total-due {
border-top: 1px solid var(--border);
margin-top: 0.375rem;
padding-top: 0.75rem;
font-size: 1.125rem;
}
.total-due span:last-child {
font-weight: 700;
}
/* Actions */
.action-row {
display: flex;
gap: 0.75rem;
}
.action-btn {
background: var(--card-bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.75rem 1.5rem;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
box-shadow: var(--shadow-sm);
}
.action-btn:hover {
box-shadow: var(--shadow-md);
}
.action-btn {
flex: 1;
}
/* In-app banner */
.in-app-banner {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem 1.25rem;
font-size: 0.875rem;
text-align: center;
line-height: 1.5;
}
.copy-link-btn {
display: block;
margin: 0.75rem auto 0;
padding: 0.5rem 1rem;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
transition: all 0.2s;
}
.copy-link-btn:hover {
box-shadow: var(--shadow-sm);
}
@media (max-width: 640px) {
.container {
padding: 2rem 1.25rem;
}
h1 {
font-size: 2rem;
}
.form-row-2 {
grid-template-columns: 1fr;
}
.row-4 {
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.row-tax {
grid-template-columns: 80px 1fr;
}
.items-table th {
font-size: 0.625rem;
}
}
```
**index.html**
```html
<div id="root"></div>
```
**config.json**
```json
{
"dependencies": {
"react": "^19.2.3",
"react-dom": "^19.2.3",
"jspdf": "^2.5.2"
},
"description": "Invoice Generator - Create simple, professional invoices as PDF."
}
```