Fintech & Payments

Invoice Builder

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.

Install

npx shadcn@latest add @paragon/invoice-builder

Also installs: number-ticker

invoice-builder.tsx

"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>
  );
}