Invoice Builder
Editable invoice lines: add rows, edit quantity and rate inline, remove lines, and watch discount, tax and total update.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/invoice-builder.json'use client'
import { useEffect, useRef, useState } from 'react'
import './invoice-builder.css'
export interface Line { id: number; desc: string; qty: number; rate: number }
export interface InvoiceBuilderProps {
defaultLines?: Omit<Line, 'id'>[]
currency?: string
tax?: number
onChange?: (lines: Line[], totals: { sub: number; discount: number; tax: number; total: number }) => void
fill?: 'dark' | 'light'
className?: string
}
/** Editable invoice lines with live totals. */
export function InvoiceBuilder({ defaultLines = [], currency = 'USD', tax = 8, onChange, fill = 'dark', className }: InvoiceBuilderProps) {
const [lines, setLines] = useState<Line[]>(() => defaultLines.map((l, i) => ({ ...l, id: i + 1 })))
const [disc, setDisc] = useState(0)
const [focusId, setFocusId] = useState<number | null>(null)
const next = useRef(defaultLines.length + 1)
const box = useRef<HTMLDivElement>(null)
const money = (n: number) => n.toLocaleString('en-US', { style: 'currency', currency })
const sub = lines.reduce((a, l) => a + l.qty * l.rate, 0)
const d = Math.min(sub, (sub * disc) / 100)
const t = ((sub - d) * tax) / 100
const total = sub - d + t
useEffect(() => { onChange?.(lines, { sub, discount: d, tax: t, total }) }, [lines, disc, tax]) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { if (focusId !== null) box.current?.querySelector<HTMLInputElement>(`[data-line="${focusId}"]`)?.focus() }, [focusId])
const edit = (id: number, k: 'desc' | 'qty' | 'rate', v: string) => setLines((ls) => ls.map((l) => (l.id === id ? { ...l, [k]: k === 'desc' ? v : Math.max(0, Number(v) || 0) } : l)))
return (
<div ref={box} className={['sd-ivb', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-ivb__h" aria-hidden><span>Item</span><span>Qty</span><span>Rate</span><span>Amount</span><span /></div>
{lines.map((l, i) => (
<div key={l.id} className="sd-ivb__row">
<input className="sd-ivb__in" data-line={l.id} aria-label={`Line ${i + 1} description`} placeholder="Description" value={l.desc} onChange={(e) => edit(l.id, 'desc', e.target.value)} />
<input className="sd-ivb__in" data-num="" type="number" inputMode="decimal" aria-label={`Line ${i + 1} quantity`} value={l.qty} onChange={(e) => edit(l.id, 'qty', e.target.value)} />
<input className="sd-ivb__in" data-num="" type="number" inputMode="decimal" aria-label={`Line ${i + 1} rate`} value={l.rate} onChange={(e) => edit(l.id, 'rate', e.target.value)} />
<span className="sd-ivb__amt">{money(l.qty * l.rate)}</span>
<button type="button" className="sd-ivb__rm" aria-label={`Remove line ${i + 1}${l.desc ? `, ${l.desc}` : ''}`} onClick={() => setLines((ls) => ls.filter((x) => x.id !== l.id))}>
<svg viewBox="0 0 14 14" aria-hidden><path d="M2.5 4h9M5.5 4V2.5h3V4M4 4l.6 7.5h4.8L10 4" /></svg>
</button>
</div>
))}
<button type="button" className="sd-ivb__add" onClick={() => { const id = next.current++; setLines((ls) => [...ls, { id, desc: '', qty: 1, rate: 0 }]); setFocusId(id) }}>+ Add line</button>
<div className="sd-ivb__tot" aria-live="polite">
<div><span>Subtotal</span>{money(sub)}</div>
<div><span className="sd-ivb__disc">Discount <input type="number" aria-label="Discount percent" value={disc} min={0} max={100} onChange={(e) => setDisc(Math.max(0, Math.min(100, Number(e.target.value) || 0)))} />%</span>−{money(d)}</div>
<div><span>Tax ({tax}%)</span>{money(t)}</div>
<div className="grand"><span>Total</span>{money(total)}</div>
</div>
</div>
)
}.sd-ivb { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-ivb[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-ivb :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-ivb :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-ivb .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-ivb { width: min(100%, 350px); padding: 14px; border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-ivb__h { display: grid; grid-template-columns: 1fr 44px 64px 64px 24px; gap: 6px; padding: 0 2px 6px; font-size: 11px; font-weight: 600; color: var(--mute); text-transform: uppercase; letter-spacing: 0.04em; }
.sd-ivb__h span:nth-child(n+2) { text-align: right; }
.sd-ivb__row { display: grid; grid-template-columns: 1fr 44px 64px 64px 24px; gap: 6px; align-items: center; padding: 3px 0; animation: sd-ivb-in 220ms ease-out; }
.sd-ivb__in { width: 100%; min-width: 0; height: 34px; padding: 0 8px; border: 0; border-radius: 8px; background: var(--soft); color: var(--fg); font: inherit; font-size: 13px; outline: 0; -moz-appearance: textfield; }
.sd-ivb__in::-webkit-inner-spin-button { -webkit-appearance: none; }
.sd-ivb__in[data-num] { text-align: right; font-variant-numeric: tabular-nums; padding: 0 6px; }
.sd-ivb__in:focus { box-shadow: inset 0 0 0 1.5px var(--fg); background: var(--bg); }
.sd-ivb__amt { font-size: 13px; text-align: right; font-weight: 600; font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; }
.sd-ivb__rm { display: grid; place-items: center; width: 24px; height: 24px; border-radius: 7px; color: var(--mute) !important; }
.sd-ivb__rm:hover { background: var(--soft); color: var(--fg) !important; }
.sd-ivb__rm svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; }
.sd-ivb__add { margin: 6px 0 12px; padding: 7px 10px; border-radius: 8px; font-size: 13px; font-weight: 500; color: var(--mute) !important; }
.sd-ivb__add:hover { background: var(--soft); color: var(--fg) !important; }
.sd-ivb__tot { display: grid; gap: 6px; padding-top: 12px; border-top: 1px solid var(--line); font-size: 13px; font-variant-numeric: tabular-nums; }
.sd-ivb__tot div { display: flex; justify-content: space-between; align-items: center; }
.sd-ivb__tot span { color: var(--mute); }
.sd-ivb__tot .grand { margin-top: 4px; padding-top: 10px; border-top: 1px dashed var(--line); font-size: 17px; font-weight: 600; }
.sd-ivb__tot .grand span { color: var(--fg); }
.sd-ivb__disc { display: inline-flex; align-items: center; gap: 6px; }
.sd-ivb__disc input { width: 52px; height: 26px; padding: 0 6px; border: 0; border-radius: 6px; background: var(--soft); color: var(--fg); font: inherit; font-size: 12.5px; text-align: right; outline: 0; }
.sd-ivb__disc input:focus { box-shadow: inset 0 0 0 1.5px var(--fg); }
@keyframes sd-ivb-in { from { opacity: 0; transform: translateY(-4px); } }
@media (prefers-reduced-motion: reduce) { .sd-ivb *, .sd-ivb *::before, .sd-ivb *::after { animation: none !important; transition: none !important; } }Overview
Each line has a description, quantity and rate; the amount computes as you type. Add line focuses the new description.
Tax rate and a discount sit below; totals update instantly and are formatted for the chosen currency.
When to use
- Invoices, quotes, estimates, purchase orders.
When not to use
- Read-only receipts: show a summary instead.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/invoice-builder.json$
npx shadcn@latest add https://sundaeui.dev/r/invoice-builder.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/invoice-builder.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/invoice-builder.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy invoice-builder.tsx and invoice-builder.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { InvoiceBuilder } from '@/components/sundae/invoice-builder'
<InvoiceBuilder />Props
| Prop | Type | Default |
|---|---|---|
defaultLinesStarting lines. | { desc: string; qty: number; rate: number }[] | — |
currencyISO currency. | string | 'USD' |
taxTax percent. | number | 8 |
onChangeCalled on change. | (lines, totals) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Inputs are labelled per line (“Line 2 quantity”).
- Totals are a live region; remove buttons name their line.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-ivb; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Invoice Builder free?
- Yes. Invoice Builder is free for personal and commercial projects, with full source on this page.
- How do I install Invoice Builder?
- Run npx shadcn@latest add https://sundaeui.dev/r/invoice-builder.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Invoice Builder support keyboard and screen readers?
- Inputs are labelled per line (“Line 2 quantity”). Totals are a live region; remove buttons name their line.
- Does Invoice Builder respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →OTP Field
Forms · Refined- Pro
Drop Zone
Forms · Refined - Pro
Onboarding Flow
Forms · Refined - Pro
Wheel Picker
Forms · Refined Password Strength
Forms · RefinedRating Scale
Forms · Refined