An editable invoice with add/remove line items where amounts, subtotal, tax, and a ticking grand total recompute live as you type quantity and rate.
npx shadcn@latest add @paragon/invoice-builderAlso installs: number-ticker
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Plus, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { NumberTicker } from "@/registry/paragon/ui/number-ticker";
export interface InvoiceLine {
id: string;
description: string;
quantity: number;
rate: number;
}
export interface InvoiceBuilderProps
extends Omit<React.ComponentProps<"div">, "onChange"> {
initialLines?: InvoiceLine[];
taxRate?: number;
currency?: string;
onChange?: (lines: InvoiceLine[], total: number) => void;
}
let seq = 0;
const newLine = (): InvoiceLine => ({
id: `line-${seq++}`,
description: "",
quantity: 1,
rate: 0,
});
/**
* An editable invoice: add and remove line items, type quantity and rate,
* and watch the amount column, subtotal, tax, and grand total recompute
* live. New rows enter with the house translate-and-fade; removed rows
* collapse out. The total rolls on a number ticker so a corrected rate reads
* as a settling figure, not a jump. All amounts are exact arithmetic.
*/
export function InvoiceBuilder({
initialLines,
taxRate = 0,
currency = "USD",
onChange,
className,
...props
}: InvoiceBuilderProps) {
const reduced = useReducedMotion();
const [lines, setLines] = React.useState<InvoiceLine[]>(
() => initialLines ?? [newLine()],
);
const money = React.useMemo(
() => new Intl.NumberFormat("en-US", { style: "currency", currency }),
[currency],
);
const subtotal = lines.reduce((sum, l) => sum + l.quantity * l.rate, 0);
const tax = subtotal * taxRate;
const total = subtotal + tax;
const changeRef = React.useRef(onChange);
changeRef.current = onChange;
React.useEffect(() => {
changeRef.current?.(lines, total);
}, [lines, total]);
function update(id: string, patch: Partial<InvoiceLine>) {
setLines((ls) => ls.map((l) => (l.id === id ? { ...l, ...patch } : l)));
}
const numField =
"h-8 w-full rounded-md border bg-transparent px-2 text-right text-sm tabular-nums outline-none transition-[border-color,box-shadow] duration-150 ease-out focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/25";
return (
<div
className={cn(
"w-full max-w-lg rounded-xl bg-card p-5 shadow-border",
className,
)}
{...props}
>
<div className="flex items-baseline justify-between">
<h3 className="text-sm font-medium">New invoice</h3>
<span className="text-[13px] text-muted-foreground tabular-nums">
{lines.length} {lines.length === 1 ? "item" : "items"}
</span>
</div>
<div className="mt-4 grid grid-cols-[1fr_3rem_5rem_5rem_2rem] gap-2 px-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
<span>Description</span>
<span className="text-right">Qty</span>
<span className="text-right">Rate</span>
<span className="text-right">Amount</span>
<span />
</div>
<ul className="mt-2 flex flex-col gap-2">
<AnimatePresence initial={false}>
{lines.map((line) => (
<motion.li
key={line.id}
layout={!reduced}
initial={
reduced ? { opacity: 0 } : { opacity: 0, y: 8, filter: "blur(4px)" }
}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={
reduced
? { opacity: 0 }
: { opacity: 0, height: 0, filter: "blur(4px)" }
}
transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
className="grid grid-cols-[1fr_3rem_5rem_5rem_2rem] items-center gap-2"
>
<input
value={line.description}
onChange={(e) => update(line.id, { description: e.target.value })}
placeholder="Item or service"
aria-label="Line description"
className="h-8 w-full min-w-0 rounded-md border bg-transparent px-2 text-sm outline-none transition-[border-color,box-shadow] duration-150 ease-out placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/25"
/>
<input
inputMode="numeric"
value={line.quantity || ""}
onChange={(e) =>
update(line.id, {
quantity: Math.max(0, Number(e.target.value.replace(/\D/g, "")) || 0),
})
}
placeholder="1"
aria-label="Quantity"
className={numField}
/>
<input
inputMode="decimal"
value={line.rate || ""}
onChange={(e) =>
update(line.id, {
rate: Math.max(0, Number(e.target.value.replace(/[^\d.]/g, "")) || 0),
})
}
placeholder="0.00"
aria-label="Rate"
className={numField}
/>
<span className="text-right text-sm font-medium tabular-nums">
{money.format(line.quantity * line.rate)}
</span>
<button
type="button"
aria-label="Remove line item"
disabled={lines.length === 1}
onClick={() =>
setLines((ls) => ls.filter((l) => l.id !== line.id))
}
className="pressable flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 ease-out hover:text-destructive disabled:pointer-events-none disabled:opacity-30"
>
<Trash2 className="size-4" aria-hidden />
</button>
</motion.li>
))}
</AnimatePresence>
</ul>
<button
type="button"
onClick={() => setLines((ls) => [...ls, newLine()])}
className="pressable mt-3 inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[13px] font-medium text-muted-foreground transition-colors duration-150 ease-out hover:text-foreground"
>
<Plus className="size-4" aria-hidden />
Add line item
</button>
<div className="mt-4 ml-auto flex w-full max-w-[16rem] flex-col gap-2 border-t pt-4 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Subtotal</span>
<span className="tabular-nums">{money.format(subtotal)}</span>
</div>
{taxRate > 0 && (
<div className="flex items-center justify-between">
<span className="text-muted-foreground">
Tax ({(taxRate * 100).toFixed(2).replace(/\.?0+$/, "")}%)
</span>
<span className="tabular-nums">{money.format(tax)}</span>
</div>
)}
<div className="flex items-center justify-between border-t pt-2 text-base font-semibold">
<span>Total</span>
<span>
<NumberTicker
value={total}
static={!!reduced}
duration={0.35}
formatOptions={{ style: "currency", currency }}
/>
</span>
</div>
</div>
</div>
);
}