Invoice Table
Original · freeinvoice line-items table
byWeb InnoventixReact + Tailwind
tableinvoicetables
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/table-invoice.jsontable-invoice.tsx
"use client"
import { useId, useMemo, useRef, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
type LineItem = {
id: string
description: string
detail: string
quantity: number
unitPrice: number
}
type Currency = {
code: string
symbol: string
label: string
}
const CURRENCIES: Currency[] = [
{ code: "USD", symbol: "$", label: "USD — US Dollar" },
{ code: "EUR", symbol: "€", label: "EUR — Euro" },
{ code: "GBP", symbol: "£", label: "GBP — Pound Sterling" },
{ code: "AUD", symbol: "$", label: "AUD — Australian Dollar" },
]
const INITIAL_ITEMS: LineItem[] = [
{
id: "li-1",
description: "Brand identity system",
detail: "Logotype, color, type scale, and usage guidelines",
quantity: 1,
unitPrice: 4800,
},
{
id: "li-2",
description: "Marketing site design",
detail: "Six responsive page layouts, delivered in Figma",
quantity: 6,
unitPrice: 620,
},
{
id: "li-3",
description: "Front-end build",
detail: "Component library + page implementation, per day",
quantity: 9,
unitPrice: 540,
},
{
id: "li-4",
description: "Launch copywriting",
detail: "Homepage, product tour, and two email sequences",
quantity: 1,
unitPrice: 1250,
},
]
function toNumber(raw: string): number {
const parsed = Number.parseFloat(raw.replace(/[^0-9.]/g, ""))
return Number.isFinite(parsed) ? parsed : 0
}
function IconPlus({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" aria-hidden="true" className={className}>
<path d="M12 5v14M5 12h14" />
</svg>
)
}
function IconMinus({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" aria-hidden="true" className={className}>
<path d="M5 12h14" />
</svg>
)
}
function IconTrash({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className={className}>
<path d="M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2m2 0-.7 12a2 2 0 0 1-2 1.9H8.7a2 2 0 0 1-2-1.9L6 7" />
<path d="M10 11v6M14 11v6" />
</svg>
)
}
function IconCheck({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className={className}>
<path d="M20 6 9 17l-5-5" />
</svg>
)
}
function IconReceipt({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className={className}>
<path d="M5 3v18l2-1.4L9 21l2-1.4L13 21l2-1.4L17 21l2-1.4V3l-2 1.4L15 3l-2 1.4L11 3 9 4.4 7 3 5 4.4Z" />
<path d="M8.5 9h7M8.5 13h5" />
</svg>
)
}
export default function TableInvoice() {
const reduce = useReducedMotion()
const uid = useId()
const idCounter = useRef(INITIAL_ITEMS.length)
const [items, setItems] = useState<LineItem[]>(INITIAL_ITEMS)
const [currencyCode, setCurrencyCode] = useState<string>("USD")
const [discountPercent, setDiscountPercent] = useState<number>(10)
const [taxPercent, setTaxPercent] = useState<number>(8.25)
const [dueDate, setDueDate] = useState<string>("2026-08-14")
const [paid, setPaid] = useState<boolean>(false)
const currency = CURRENCIES.find((c) => c.code === currencyCode) ?? CURRENCIES[0]
const formatter = useMemo(
() => new Intl.NumberFormat("en-US", { style: "currency", currency: currency.code }),
[currency.code],
)
const money = (value: number) => formatter.format(value)
const subtotal = items.reduce((sum, it) => sum + it.quantity * it.unitPrice, 0)
const discountAmount = subtotal * (discountPercent / 100)
const taxable = Math.max(0, subtotal - discountAmount)
const taxAmount = taxable * (taxPercent / 100)
const total = taxable + taxAmount
function updateItem(id: string, patch: Partial<LineItem>) {
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, ...patch } : it)))
}
function stepQuantity(id: string, delta: number) {
setItems((prev) =>
prev.map((it) => (it.id === id ? { ...it, quantity: Math.max(0, it.quantity + delta) } : it)),
)
}
function removeItem(id: string) {
setItems((prev) => prev.filter((it) => it.id !== id))
}
function addItem() {
idCounter.current += 1
setItems((prev) => [
...prev,
{
id: `li-${idCounter.current}`,
description: "New line item",
detail: "Describe the deliverable",
quantity: 1,
unitPrice: 0,
},
])
}
const rowMotion = reduce
? {}
: {
initial: { opacity: 0, y: -6 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -6, transition: { duration: 0.16 } },
transition: { duration: 0.24, ease: [0.22, 1, 0.36, 1] as const },
}
const inputBase =
"w-full rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-slate-900 placeholder:text-slate-400 outline-none transition focus-visible:border-indigo-400 focus-visible:ring-2 focus-visible:ring-indigo-500/40 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:placeholder:text-slate-500 dark:focus-visible:border-indigo-500"
const focusRing =
"outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950"
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 text-slate-900 sm:px-6 sm:py-24 dark:bg-slate-950 dark:text-slate-100">
<style>{`
@keyframes invtbl-sheen {
0% { background-position: -140% 0; }
100% { background-position: 240% 0; }
}
@keyframes invtbl-rise {
0% { opacity: 0; transform: translateY(10px); }
100% { opacity: 1; transform: translateY(0); }
}
.invtbl-rise { animation: invtbl-rise 0.5s cubic-bezier(0.22,1,0.36,1) both; }
.invtbl-sheen {
background-image: linear-gradient(110deg, transparent 30%, rgba(255,255,255,0.55) 50%, transparent 70%);
background-size: 220% 100%;
animation: invtbl-sheen 3.6s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.invtbl-rise, .invtbl-sheen { animation: none !important; }
}
`}</style>
{/* Ambient backdrop */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
<div className="absolute -top-24 left-1/2 h-72 w-[42rem] -translate-x-1/2 rounded-full bg-indigo-300/25 blur-3xl dark:bg-indigo-600/15" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_1px_1px,rgba(15,23,42,0.05)_1px,transparent_0)] bg-[size:22px_22px] dark:bg-[radial-gradient(circle_at_1px_1px,rgba(148,163,184,0.08)_1px,transparent_0)]" />
</div>
<div className="invtbl-rise relative mx-auto w-full max-w-5xl">
<div className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-[0_24px_60px_-24px_rgba(30,27,75,0.35)] dark:border-slate-800 dark:bg-slate-900 dark:shadow-[0_24px_60px_-24px_rgba(0,0,0,0.7)]">
{/* Header */}
<header className="relative border-b border-slate-200 bg-gradient-to-br from-indigo-600 via-violet-600 to-indigo-700 px-6 py-8 text-white sm:px-8 dark:border-slate-800">
<div className="pointer-events-none absolute inset-0 opacity-40 [background:radial-gradient(60%_100%_at_100%_0%,rgba(255,255,255,0.25),transparent)]" />
<div className="relative flex flex-wrap items-start justify-between gap-6">
<div className="flex items-start gap-4">
<span className="grid size-12 shrink-0 place-items-center rounded-xl bg-white/15 ring-1 ring-white/25 backdrop-blur">
<IconReceipt className="size-6 text-white" />
</span>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-indigo-100">Invoice</p>
<p className="mt-0.5 text-2xl font-semibold tracking-tight">Halden & Rowe Studio</p>
<p className="mt-1 text-sm text-indigo-100/90">hello@haldenrowe.studio · +1 (415) 555-0139</p>
</div>
</div>
<div className="text-right">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-indigo-100">No.</p>
<p className="font-mono text-lg font-semibold tracking-tight">HR-2026-0412</p>
<span
className={`mt-2 inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wide ring-1 ${
paid
? "bg-emerald-400/20 text-emerald-50 ring-emerald-200/50"
: "bg-amber-400/20 text-amber-50 ring-amber-200/50"
}`}
>
<span className={`size-1.5 rounded-full ${paid ? "bg-emerald-300" : "bg-amber-300"}`} />
{paid ? "Paid" : "Due"}
</span>
</div>
</div>
<div className="relative mt-8 grid gap-6 sm:grid-cols-3">
<div>
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.2em] text-indigo-100/80">Billed to</p>
<p className="mt-1.5 text-sm font-medium leading-relaxed text-white">
Cascade Coffee Roasters
<br />
<span className="text-indigo-100/90">411 Marlow Ave, Portland, OR 97204</span>
</p>
</div>
<div>
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.2em] text-indigo-100/80">Issued</p>
<p className="mt-1.5 text-sm font-medium text-white">July 16, 2026</p>
</div>
<div>
<label htmlFor={`${uid}-due`} className="text-[0.68rem] font-semibold uppercase tracking-[0.2em] text-indigo-100/80">
Payment due
</label>
<input
id={`${uid}-due`}
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className={`mt-1.5 w-full rounded-lg border border-white/25 bg-white/10 px-2.5 py-1.5 text-sm font-medium text-white [color-scheme:dark] placeholder:text-indigo-100 ${focusRing} focus-visible:ring-offset-indigo-700`}
/>
</div>
</div>
</header>
{/* Line-items table */}
<div className="px-2 py-2 sm:px-4 sm:py-4">
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] border-separate border-spacing-0 text-sm">
<caption className="sr-only">Editable invoice line items with quantity, unit price, and amount.</caption>
<thead>
<tr className="text-left text-[0.68rem] uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
<th scope="col" className="px-3 py-3 font-semibold">Description</th>
<th scope="col" className="px-3 py-3 text-center font-semibold">Qty</th>
<th scope="col" className="px-3 py-3 text-right font-semibold">Unit price</th>
<th scope="col" className="px-3 py-3 text-right font-semibold">Amount</th>
<th scope="col" className="px-3 py-3">
<span className="sr-only">Remove</span>
</th>
</tr>
</thead>
<tbody>
<AnimatePresence initial={false}>
{items.map((item) => {
const amount = item.quantity * item.unitPrice
return (
<motion.tr
key={item.id}
layout={!reduce}
{...rowMotion}
className="group align-top"
>
{/* Description */}
<td className="border-t border-slate-100 px-3 py-3 dark:border-slate-800">
<input
aria-label="Line item description"
value={item.description}
onChange={(e) => updateItem(item.id, { description: e.target.value })}
className={`${inputBase} font-medium`}
/>
<input
aria-label="Line item detail"
value={item.detail}
onChange={(e) => updateItem(item.id, { detail: e.target.value })}
className={`${inputBase} mt-1.5 text-xs text-slate-500 dark:text-slate-400`}
/>
</td>
{/* Quantity stepper */}
<td className="border-t border-slate-100 px-3 py-3 dark:border-slate-800">
<div className="mx-auto flex w-[7.5rem] items-center rounded-lg border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900">
<button
type="button"
aria-label={`Decrease quantity for ${item.description}`}
onClick={() => stepQuantity(item.id, -1)}
className={`grid size-8 shrink-0 place-items-center rounded-l-lg text-slate-500 transition hover:bg-slate-100 hover:text-slate-900 disabled:opacity-40 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white ${focusRing}`}
disabled={item.quantity <= 0}
>
<IconMinus className="size-3.5" />
</button>
<input
aria-label={`Quantity for ${item.description}`}
type="number"
min={0}
inputMode="numeric"
value={item.quantity}
onChange={(e) => updateItem(item.id, { quantity: Math.max(0, Math.round(toNumber(e.target.value))) })}
className={`w-full min-w-0 border-x border-slate-200 bg-transparent py-1.5 text-center font-mono text-sm tabular-nums text-slate-900 outline-none [appearance:textfield] focus-visible:bg-indigo-50 dark:border-slate-700 dark:text-slate-100 dark:focus-visible:bg-slate-800 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none`}
/>
<button
type="button"
aria-label={`Increase quantity for ${item.description}`}
onClick={() => stepQuantity(item.id, 1)}
className={`grid size-8 shrink-0 place-items-center rounded-r-lg text-slate-500 transition hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white ${focusRing}`}
>
<IconPlus className="size-3.5" />
</button>
</div>
</td>
{/* Unit price */}
<td className="border-t border-slate-100 px-3 py-3 dark:border-slate-800">
<div className="relative ml-auto w-[8.5rem]">
<span className="pointer-events-none absolute inset-y-0 left-2.5 grid place-items-center font-mono text-sm text-slate-400 dark:text-slate-500">
{currency.symbol}
</span>
<input
aria-label={`Unit price for ${item.description}`}
type="number"
min={0}
step={0.01}
inputMode="decimal"
value={item.unitPrice}
onChange={(e) => updateItem(item.id, { unitPrice: Math.max(0, toNumber(e.target.value)) })}
className={`${inputBase} pl-6 text-right font-mono tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none`}
/>
</div>
</td>
{/* Amount */}
<td className="border-t border-slate-100 px-3 py-3 text-right align-middle dark:border-slate-800">
<span className="font-mono text-sm font-semibold tabular-nums text-slate-900 dark:text-slate-100">
{money(amount)}
</span>
</td>
{/* Remove */}
<td className="border-t border-slate-100 px-3 py-3 text-right align-middle dark:border-slate-800">
<button
type="button"
aria-label={`Remove ${item.description}`}
onClick={() => removeItem(item.id)}
className={`grid size-8 place-items-center rounded-lg text-slate-400 opacity-70 transition hover:bg-rose-50 hover:text-rose-600 hover:opacity-100 group-hover:opacity-100 dark:text-slate-500 dark:hover:bg-rose-950/50 dark:hover:text-rose-400 ${focusRing}`}
>
<IconTrash className="size-4" />
</button>
</td>
</motion.tr>
)
})}
</AnimatePresence>
{items.length === 0 && (
<tr>
<td colSpan={5} className="border-t border-slate-100 px-3 py-10 text-center text-sm text-slate-500 dark:border-slate-800 dark:text-slate-400">
No line items yet. Add your first deliverable below.
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="px-1 pb-1 pt-3">
<button
type="button"
onClick={addItem}
className={`inline-flex items-center gap-2 rounded-xl border border-dashed border-slate-300 px-3.5 py-2 text-sm font-medium text-slate-600 transition hover:border-indigo-400 hover:bg-indigo-50 hover:text-indigo-700 dark:border-slate-700 dark:text-slate-300 dark:hover:border-indigo-500 dark:hover:bg-indigo-950/40 dark:hover:text-indigo-300 ${focusRing}`}
>
<IconPlus className="size-4" />
Add line item
</button>
</div>
</div>
{/* Footer: terms + totals */}
<div className="grid gap-8 border-t border-slate-200 px-6 py-8 sm:px-8 md:grid-cols-2 dark:border-slate-800">
<div className="space-y-5">
<div>
<h3 className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">Notes & terms</h3>
<p className="mt-2 max-w-sm text-sm leading-relaxed text-slate-600 dark:text-slate-300">
Payment is due within 30 days of the issue date. A 1.5% monthly service charge applies to balances past due. Bank transfer and card accepted.
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<label htmlFor={`${uid}-currency`} className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
Currency
</label>
<select
id={`${uid}-currency`}
value={currencyCode}
onChange={(e) => setCurrencyCode(e.target.value)}
className={`rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm font-medium text-slate-800 transition dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 ${focusRing}`}
>
{CURRENCIES.map((c) => (
<option key={c.code} value={c.code}>
{c.label}
</option>
))}
</select>
</div>
<button
type="button"
role="switch"
aria-checked={paid}
onClick={() => setPaid((p) => !p)}
className={`inline-flex items-center gap-3 rounded-full py-1 ${focusRing}`}
>
<span
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors ${
paid ? "bg-emerald-500" : "bg-slate-300 dark:bg-slate-700"
}`}
>
<span
className={`inline-flex size-5 translate-x-0.5 items-center justify-center rounded-full bg-white shadow transition-transform ${
paid ? "translate-x-[1.375rem]" : ""
}`}
>
{paid && <IconCheck className="size-3 text-emerald-600" />}
</span>
</span>
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">
{paid ? "Marked as paid" : "Mark invoice as paid"}
</span>
</button>
</div>
{/* Totals */}
<div className="rounded-2xl border border-slate-200 bg-slate-50/70 p-5 dark:border-slate-800 dark:bg-slate-950/40">
<dl className="space-y-3 text-sm">
<div className="flex items-center justify-between">
<dt className="text-slate-600 dark:text-slate-300">Subtotal</dt>
<dd className="font-mono tabular-nums text-slate-900 dark:text-slate-100">{money(subtotal)}</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
<span>Discount</span>
<span className="inline-flex items-center rounded-md border border-slate-200 bg-white px-1.5 dark:border-slate-700 dark:bg-slate-900">
<label htmlFor={`${uid}-discount`} className="sr-only">Discount percentage</label>
<input
id={`${uid}-discount`}
type="number"
min={0}
max={100}
step={0.5}
inputMode="decimal"
value={discountPercent}
onChange={(e) => setDiscountPercent(Math.min(100, Math.max(0, toNumber(e.target.value))))}
className={`w-11 bg-transparent py-0.5 text-right font-mono text-xs tabular-nums text-slate-800 outline-none [appearance:textfield] dark:text-slate-100 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none ${focusRing} rounded`}
/>
<span className="pl-0.5 text-xs text-slate-400 dark:text-slate-500">%</span>
</span>
</dt>
<dd className="font-mono tabular-nums text-rose-600 dark:text-rose-400">
{discountAmount > 0 ? `- ${money(discountAmount)}` : money(0)}
</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
<span>Tax</span>
<span className="inline-flex items-center rounded-md border border-slate-200 bg-white px-1.5 dark:border-slate-700 dark:bg-slate-900">
<label htmlFor={`${uid}-tax`} className="sr-only">Tax percentage</label>
<input
id={`${uid}-tax`}
type="number"
min={0}
max={100}
step={0.05}
inputMode="decimal"
value={taxPercent}
onChange={(e) => setTaxPercent(Math.min(100, Math.max(0, toNumber(e.target.value))))}
className={`w-12 bg-transparent py-0.5 text-right font-mono text-xs tabular-nums text-slate-800 outline-none [appearance:textfield] dark:text-slate-100 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none ${focusRing} rounded`}
/>
<span className="pl-0.5 text-xs text-slate-400 dark:text-slate-500">%</span>
</span>
</dt>
<dd className="font-mono tabular-nums text-slate-900 dark:text-slate-100">{money(taxAmount)}</dd>
</div>
<div className="mt-2 border-t border-dashed border-slate-300 pt-4 dark:border-slate-700">
<div className="flex items-end justify-between">
<dt className="text-sm font-semibold uppercase tracking-wide text-slate-700 dark:text-slate-200">
Total due
</dt>
<dd
aria-live="polite"
className="font-mono text-2xl font-bold tabular-nums text-slate-900 dark:text-white"
>
{money(total)}
</dd>
</div>
<p className="mt-1 text-right text-xs text-slate-500 dark:text-slate-400">
{items.length} item{items.length === 1 ? "" : "s"} · {currency.code}
</p>
</div>
</dl>
<button
type="button"
className={`relative mt-5 w-full overflow-hidden rounded-xl bg-gradient-to-r from-indigo-600 to-violet-600 px-4 py-2.5 text-sm font-semibold text-white shadow-lg shadow-indigo-600/25 transition hover:from-indigo-500 hover:to-violet-500 ${focusRing}`}
>
<span aria-hidden="true" className="invtbl-sheen pointer-events-none absolute inset-0" />
<span className="relative">Send invoice · {money(total)}</span>
</button>
</div>
</div>
</div>
<p className="mt-4 text-center text-xs text-slate-400 dark:text-slate-500">
Every field is editable — adjust quantities, prices, discount, and tax to watch the total recalculate live.
</p>
</div>
</section>
)
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Basic Table
Originalclean basic data table

Striped Table
Originalstriped-row table

Bordered Table
Originalbordered table

Hover Table
Originalrow-hover highlight table

Sortable Table
Originalsortable-column table

Selectable Table
Originaltable with row checkboxes and select-all

Sticky Header Table
Originalsticky header scroll table

Pagination Table
Originaltable with footer pagination

Search Table
Originaltable with a search filter

Expandable Table
Originaltable with expandable rows

Actions Table
Originaltable with row action menus

Status Badges Table
Originaltable with status badges

