/* Invoice builder. Each line's amount is qty × unit price, rounded to the currency's minor unit; discount and tax are
percentages; Intl.NumberFormat formats every figure for the chosen currency and locale. Front end only: the
form never submits. Line items are a real table (desktop) or a list of fieldsets (narrow panel), all labelled. */
.ib{--ib-accent:#0f766e;--ib-accent-dark:#115e59;--ib-ink:#13201f;--ib-muted:#4a5b59;--ib-line:#dbe5e3;--ib-field:#71817f;--ib-soft:#f1f6f5;--ib-ring:rgba(15,118,110,.28);
box-sizing:border-box;background:#fff;color:var(--ib-ink);border-radius:18px;padding:28px;font-size:14px;line-height:1.45;
box-shadow:0 1px 2px rgba(19,32,31,.06),0 24px 48px -30px rgba(19,32,31,.4)}
.ib *,.ib *::before,.ib *::after{box-sizing:border-box}
:where(.ib) :is(h2,p,dl,dd,fieldset,ol,table){margin:0}
.ib .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.ib :where(input,select,textarea,button){font:inherit;color:inherit}
.ib-head{display:flex;justify-content:space-between;align-items:flex-start;gap:16px;margin-bottom:22px}
.ib-title{font-size:1.35rem;font-weight:700;letter-spacing:-.01em}
.ib-sub{color:var(--ib-muted);font-size:.86rem;margin-top:2px}
.ib-btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:10px 18px;border:0;border-radius:10px;background:var(--ib-accent);color:#fff;font-weight:700;cursor:pointer;transition:background-color .15s}
.ib-btn:hover{background:var(--ib-accent-dark)}
.ib-btn--full{width:100%;margin-top:18px;padding:12px}
.ib-fs{border:0;padding:0;min-width:0;margin-bottom:22px}
.ib-legend{font-size:.74rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--ib-muted);margin-bottom:10px;padding:0}
.ib-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}
.ib-field--2{grid-column:span 2}
.ib-label{display:block;font-size:.8rem;font-weight:600;margin-bottom:5px}
.ib-input{display:block;width:100%;min-height:40px;padding:8px 10px;border:1px solid var(--ib-field);border-radius:9px;background:#fff;transition:border-color .15s,box-shadow .15s}
.ib-input:focus{outline:none;border-color:var(--ib-accent);box-shadow:0 0 0 3px var(--ib-ring)}
.ib-input:focus-visible{outline:2px solid var(--ib-accent);outline-offset:1px}
.ib-num{text-align:right;font-variant-numeric:tabular-nums}
.ib-select{appearance:none;padding-right:30px;background:#fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2313201f' stroke-width='2.5'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E") no-repeat right 10px center}
.ib-textarea{min-height:96px;resize:vertical}
.ib-scroll{position:relative;overflow-x:auto;margin:0 -4px;padding:0 4px}
.ib-table{width:100%;min-width:560px;border-collapse:collapse;font-variant-numeric:tabular-nums}
.ib-caption{text-align:left;font-size:.74rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--ib-muted);padding-bottom:8px}
.ib-table thead th{padding:8px 6px;background:var(--ib-soft);font-size:.74rem;font-weight:600;color:var(--ib-muted);text-align:left;text-transform:uppercase;letter-spacing:.06em}
.ib-table thead th:first-child{border-radius:8px 0 0 8px}.ib-table thead th:last-child{border-radius:0 8px 8px 0}
.ib-table th:nth-child(2){width:84px}.ib-table th:nth-child(3){width:120px}.ib-table th:nth-child(4){width:120px}.ib-table th:nth-child(5){width:48px}
.ib-table td{padding:8px 6px;border-bottom:1px solid var(--ib-line);vertical-align:middle}
.ib-table .num{text-align:right}
.ib-amt{font-weight:600;white-space:nowrap}
.ib-rm{display:inline-grid;place-items:center;width:36px;height:36px;border:1px solid transparent;border-radius:9px;background:none;color:var(--ib-muted);cursor:pointer;transition:background-color .15s,color .15s}
.ib-rm:hover{background:#fdecea;color:#b42318}
.ib-rm[hidden],.ib-add[hidden]{display:none}
.ib-add{display:inline-flex;align-items:center;gap:6px;margin-top:12px;padding:9px 14px;border:1.5px dashed var(--ib-field);border-radius:10px;background:none;color:var(--ib-accent-dark);font-weight:700;cursor:pointer;transition:border-color .15s,background-color .15s}
.ib-add:hover{border-color:var(--ib-accent);background:var(--ib-soft)}
.ib :is(.ib-btn,.ib-rm,.ib-add):focus-visible{outline:2px solid var(--ib-accent);outline-offset:2px}
.ib-bottom{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:28px;margin-top:24px;align-items:start}
.ib-adj{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:14px}
.ib-totals{display:grid;gap:6px;padding:16px;border-radius:12px;background:var(--ib-soft);font-variant-numeric:tabular-nums}
.ib-trow{display:flex;justify-content:space-between;gap:12px}
.ib-trow dt{color:var(--ib-muted)}
.ib-trow dd{font-weight:600}
.ib-trow--total{margin-top:6px;padding-top:10px;border-top:1px solid #c3d3d0;align-items:baseline}
.ib-trow--total dt{color:var(--ib-ink);font-weight:700}
.ib-trow--total dd{font-size:1.45rem;font-weight:800;color:var(--ib-accent-dark)}
/* compact variant */
.ib--narrow{max-width:400px;padding:22px}
.ib--narrow>.ib-field{margin-bottom:14px}
.ib-clist{list-style:none;padding:0;display:grid;gap:10px}
.ib-cfs{position:relative;display:grid;grid-template-columns:1fr 1fr;gap:10px;padding:12px 14px 14px;border:1px solid var(--ib-line);border-radius:12px;min-width:0}
.ib-clegend{float:left;font-size:.74rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--ib-muted);padding:0}
.ib-clegend+*{clear:left}
.ib-cfull{grid-column:1/-1}
.ib-camt{grid-column:1/-1;display:flex;justify-content:space-between;font-variant-numeric:tabular-nums}
.ib-camt span{color:var(--ib-muted)}
.ib-crm{position:absolute;top:4px;right:6px;width:32px;height:32px}
.ib--narrow .ib-adj{grid-template-columns:1fr;margin-top:16px}
@media (max-width:720px){.ib-grid{grid-template-columns:1fr 1fr}.ib-bottom{grid-template-columns:1fr}}
@media (max-width:480px){.ib{padding:20px 16px}.ib-grid{grid-template-columns:1fr}.ib-field--2{grid-column:auto}.ib-head{flex-wrap:wrap}}
@media (prefers-reduced-motion:reduce){.ib-input,.ib-btn,.ib-rm,.ib-add{transition:none}}
JavaScript
// Invoice builder: add/remove lines, qty × price, discount %, tax %, totals in the chosen currency.
// Amounts are rounded per line in the currency's minor unit (cents, pence; none for yen), so they always add up.
// Front end only: nothing is saved or sent. Hooks are data attributes, so any stylesheet works.
document.querySelectorAll('[data-builder]').forEach(function (root) {
var lines = root.querySelector('[data-lines]');
var tpl = root.querySelector('template[data-line-template]');
var addBtn = root.querySelector('[data-add]');
var currency = root.querySelector('[data-currency]');
var tax = root.querySelector('[data-tax]');
var discount = root.querySelector('[data-discount]');
var status = root.querySelector('[data-status]');
if (!lines || !tpl || !currency) return;
var uid = lines.querySelectorAll('[data-line]').length;
function locale() { var o = currency.selectedOptions[0]; return (o && o.dataset.locale) || 'en-US'; }
function fmt(minor, digits) {
return new Intl.NumberFormat(locale(), { style: 'currency', currency: currency.value }).format(minor / Math.pow(10, digits));
}
function digits() { return new Intl.NumberFormat('en', { style: 'currency', currency: currency.value }).resolvedOptions().maximumFractionDigits; }
function val(input, max) { var n = parseFloat(input && input.value); if (!isFinite(n) || n < 0) n = 0; return max ? Math.min(n, max) : n; }
function out(name, text) { var el = root.querySelector('[data-out="' + name + '"]'); if (el) el.textContent = text; }
function recalc() {
var d = digits(), f = Math.pow(10, d), sub = 0;
lines.querySelectorAll('[data-line]').forEach(function (line, i) {
var amount = Math.round(val(line.querySelector('[data-field="qty"]')) * val(line.querySelector('[data-field="price"]')) * f);
sub += amount;
line.querySelector('[data-amount]').textContent = fmt(amount, d);
line.querySelectorAll('[data-n]').forEach(function (n) { n.textContent = i + 1; });
var rm = line.querySelector('[data-remove]'); if (rm) rm.setAttribute('aria-label', 'Remove line ' + (i + 1));
});
var off = Math.round(sub * val(discount, 100) / 100);
var taxed = Math.round((sub - off) * val(tax, 100) / 100);
out('subtotal', fmt(sub, d)); out('discount', off ? '−' + fmt(off, d) : fmt(0, d)); out('tax', fmt(taxed, d)); out('total', fmt(sub - off + taxed, d));
root.querySelectorAll('[data-out-rate="discount"]').forEach(function (s) { s.textContent = val(discount, 100); });
root.querySelectorAll('[data-out-rate="tax"]').forEach(function (s) { s.textContent = val(tax, 100); });
return fmt(sub - off + taxed, d);
}
function say(text) { if (status) { status.textContent = ''; setTimeout(function () { status.textContent = text; }, 50); } }
function addLine() {
uid += 1;
var frag = tpl.content.cloneNode(true), line = frag.querySelector('[data-line]');
line.querySelectorAll('[data-field]').forEach(function (input) {
var id = root.id + '-' + input.dataset.field + '-' + uid; input.id = id;
var label = line.querySelector('[data-label="' + input.dataset.field + '"]'); if (label) label.htmlFor = id;
});
lines.appendChild(frag);
var total = recalc();
line.querySelector('[data-field="desc"]').focus();
say('Line ' + lines.querySelectorAll('[data-line]').length + ' added. Total ' + total + '.');
}
root.addEventListener('click', function (e) {
var rm = e.target.closest('[data-remove]');
if (!rm) return;
var line = rm.closest('[data-line]');
var next = line.nextElementSibling || line.previousElementSibling;
line.remove();
var total = recalc();
(next && next.matches('[data-line]') ? next.querySelector('[data-remove]') : addBtn).focus();
say('Line removed. Total ' + total + '.');
});
root.addEventListener('input', recalc);
root.addEventListener('change', function (e) {
if (e.target.matches('[data-field="qty"],[data-field="price"],[data-tax],[data-discount],[data-currency]')) say('Total ' + recalc() + '.');
});
root.addEventListener('submit', function (e) { e.preventDefault(); say('Demo only: this invoice was not saved or sent. Total ' + recalc() + '.'); });
if (addBtn) { addBtn.hidden = false; addBtn.addEventListener('click', addLine); }
root.querySelectorAll('[data-remove]').forEach(function (b) { b.hidden = false; });
recalc();
});
// Invoice builder: add/remove lines, qty × price, discount %, tax %, totals in the chosen currency.
// Amounts are rounded per line in the currency's minor unit (cents, pence; none for yen), so they always add up.
// Front end only: nothing is saved or sent. Hooks are data attributes, so any stylesheet works.
document.querySelectorAll('[data-builder]').forEach(function (root) {
var lines = root.querySelector('[data-lines]');
var tpl = root.querySelector('template[data-line-template]');
var addBtn = root.querySelector('[data-add]');
var currency = root.querySelector('[data-currency]');
var tax = root.querySelector('[data-tax]');
var discount = root.querySelector('[data-discount]');
var status = root.querySelector('[data-status]');
if (!lines || !tpl || !currency) return;
var uid = lines.querySelectorAll('[data-line]').length;
function locale() { var o = currency.selectedOptions[0]; return (o && o.dataset.locale) || 'en-US'; }
function fmt(minor, digits) {
return new Intl.NumberFormat(locale(), { style: 'currency', currency: currency.value }).format(minor / Math.pow(10, digits));
}
function digits() { return new Intl.NumberFormat('en', { style: 'currency', currency: currency.value }).resolvedOptions().maximumFractionDigits; }
function val(input, max) { var n = parseFloat(input && input.value); if (!isFinite(n) || n < 0) n = 0; return max ? Math.min(n, max) : n; }
function out(name, text) { var el = root.querySelector('[data-out="' + name + '"]'); if (el) el.textContent = text; }
function recalc() {
var d = digits(), f = Math.pow(10, d), sub = 0;
lines.querySelectorAll('[data-line]').forEach(function (line, i) {
var amount = Math.round(val(line.querySelector('[data-field="qty"]')) * val(line.querySelector('[data-field="price"]')) * f);
sub += amount;
line.querySelector('[data-amount]').textContent = fmt(amount, d);
line.querySelectorAll('[data-n]').forEach(function (n) { n.textContent = i + 1; });
var rm = line.querySelector('[data-remove]'); if (rm) rm.setAttribute('aria-label', 'Remove line ' + (i + 1));
});
var off = Math.round(sub * val(discount, 100) / 100);
var taxed = Math.round((sub - off) * val(tax, 100) / 100);
out('subtotal', fmt(sub, d)); out('discount', off ? '−' + fmt(off, d) : fmt(0, d)); out('tax', fmt(taxed, d)); out('total', fmt(sub - off + taxed, d));
root.querySelectorAll('[data-out-rate="discount"]').forEach(function (s) { s.textContent = val(discount, 100); });
root.querySelectorAll('[data-out-rate="tax"]').forEach(function (s) { s.textContent = val(tax, 100); });
return fmt(sub - off + taxed, d);
}
function say(text) { if (status) { status.textContent = ''; setTimeout(function () { status.textContent = text; }, 50); } }
function addLine() {
uid += 1;
var frag = tpl.content.cloneNode(true), line = frag.querySelector('[data-line]');
line.querySelectorAll('[data-field]').forEach(function (input) {
var id = root.id + '-' + input.dataset.field + '-' + uid; input.id = id;
var label = line.querySelector('[data-label="' + input.dataset.field + '"]'); if (label) label.htmlFor = id;
});
lines.appendChild(frag);
var total = recalc();
line.querySelector('[data-field="desc"]').focus();
say('Line ' + lines.querySelectorAll('[data-line]').length + ' added. Total ' + total + '.');
}
root.addEventListener('click', function (e) {
var rm = e.target.closest('[data-remove]');
if (!rm) return;
var line = rm.closest('[data-line]');
var next = line.nextElementSibling || line.previousElementSibling;
line.remove();
var total = recalc();
(next && next.matches('[data-line]') ? next.querySelector('[data-remove]') : addBtn).focus();
say('Line removed. Total ' + total + '.');
});
root.addEventListener('input', recalc);
root.addEventListener('change', function (e) {
if (e.target.matches('[data-field="qty"],[data-field="price"],[data-tax],[data-discount],[data-currency]')) say('Total ' + recalc() + '.');
});
root.addEventListener('submit', function (e) { e.preventDefault(); say('Demo only: this invoice was not saved or sent. Total ' + recalc() + '.'); });
if (addBtn) { addBtn.hidden = false; addBtn.addEventListener('click', addLine); }
root.querySelectorAll('[data-remove]').forEach(function (b) { b.hidden = false; });
recalc();
});
// Invoice builder: add/remove lines, qty × price, discount %, tax %, totals in the chosen currency.
// Amounts are rounded per line in the currency's minor unit (cents, pence; none for yen), so they always add up.
// Front end only: nothing is saved or sent. Hooks are data attributes, so any stylesheet works.
document.querySelectorAll('[data-builder]').forEach(function (root) {
var lines = root.querySelector('[data-lines]');
var tpl = root.querySelector('template[data-line-template]');
var addBtn = root.querySelector('[data-add]');
var currency = root.querySelector('[data-currency]');
var tax = root.querySelector('[data-tax]');
var discount = root.querySelector('[data-discount]');
var status = root.querySelector('[data-status]');
if (!lines || !tpl || !currency) return;
var uid = lines.querySelectorAll('[data-line]').length;
function locale() { var o = currency.selectedOptions[0]; return (o && o.dataset.locale) || 'en-US'; }
function fmt(minor, digits) {
return new Intl.NumberFormat(locale(), { style: 'currency', currency: currency.value }).format(minor / Math.pow(10, digits));
}
function digits() { return new Intl.NumberFormat('en', { style: 'currency', currency: currency.value }).resolvedOptions().maximumFractionDigits; }
function val(input, max) { var n = parseFloat(input && input.value); if (!isFinite(n) || n < 0) n = 0; return max ? Math.min(n, max) : n; }
function out(name, text) { var el = root.querySelector('[data-out="' + name + '"]'); if (el) el.textContent = text; }
function recalc() {
var d = digits(), f = Math.pow(10, d), sub = 0;
lines.querySelectorAll('[data-line]').forEach(function (line, i) {
var amount = Math.round(val(line.querySelector('[data-field="qty"]')) * val(line.querySelector('[data-field="price"]')) * f);
sub += amount;
line.querySelector('[data-amount]').textContent = fmt(amount, d);
line.querySelectorAll('[data-n]').forEach(function (n) { n.textContent = i + 1; });
var rm = line.querySelector('[data-remove]'); if (rm) rm.setAttribute('aria-label', 'Remove line ' + (i + 1));
});
var off = Math.round(sub * val(discount, 100) / 100);
var taxed = Math.round((sub - off) * val(tax, 100) / 100);
out('subtotal', fmt(sub, d)); out('discount', off ? '−' + fmt(off, d) : fmt(0, d)); out('tax', fmt(taxed, d)); out('total', fmt(sub - off + taxed, d));
root.querySelectorAll('[data-out-rate="discount"]').forEach(function (s) { s.textContent = val(discount, 100); });
root.querySelectorAll('[data-out-rate="tax"]').forEach(function (s) { s.textContent = val(tax, 100); });
return fmt(sub - off + taxed, d);
}
function say(text) { if (status) { status.textContent = ''; setTimeout(function () { status.textContent = text; }, 50); } }
function addLine() {
uid += 1;
var frag = tpl.content.cloneNode(true), line = frag.querySelector('[data-line]');
line.querySelectorAll('[data-field]').forEach(function (input) {
var id = root.id + '-' + input.dataset.field + '-' + uid; input.id = id;
var label = line.querySelector('[data-label="' + input.dataset.field + '"]'); if (label) label.htmlFor = id;
});
lines.appendChild(frag);
var total = recalc();
line.querySelector('[data-field="desc"]').focus();
say('Line ' + lines.querySelectorAll('[data-line]').length + ' added. Total ' + total + '.');
}
root.addEventListener('click', function (e) {
var rm = e.target.closest('[data-remove]');
if (!rm) return;
var line = rm.closest('[data-line]');
var next = line.nextElementSibling || line.previousElementSibling;
line.remove();
var total = recalc();
(next && next.matches('[data-line]') ? next.querySelector('[data-remove]') : addBtn).focus();
say('Line removed. Total ' + total + '.');
});
root.addEventListener('input', recalc);
root.addEventListener('change', function (e) {
if (e.target.matches('[data-field="qty"],[data-field="price"],[data-tax],[data-discount],[data-currency]')) say('Total ' + recalc() + '.');
});
root.addEventListener('submit', function (e) { e.preventDefault(); say('Demo only: this invoice was not saved or sent. Total ' + recalc() + '.'); });
if (addBtn) { addBtn.hidden = false; addBtn.addEventListener('click', addLine); }
root.querySelectorAll('[data-remove]').forEach(function (b) { b.hidden = false; });
recalc();
});
About this set
An editable invoice form that recalculates as you type. Invoice builder with editable line items has fields for the invoice number, issue and due dates, currency, client and email, a table of line items with description, quantity and unit price inputs, Add line and remove buttons, notes for the client, discount and tax percentages, and a totals panel. Compact builder for a narrow panel is the same tool for a sidebar or phone: each line is a fieldset with visible labels, and VAT is the only adjustment. A small vanilla script multiplies quantity by price for each line, rounds every amount to the currency’s minor unit so the figures always add up, applies the discount and tax, and formats everything with Intl.NumberFormat for the chosen currency and locale, so switching to euros shows 5.277,03 € and yen drops the decimals. It is front end only: the form never submits, and a polite status line says so. Without JavaScript the form still shows the starting figures, and the add and remove buttons stay hidden because they cannot work.
What’s included
Add and remove line items, quantity times unit price per line
Discount and tax percentages with a live subtotal, discount, tax and total
Currency select formatted with Intl.NumberFormat (USD, EUR, GBP, JPY, CAD)
Totals rounded in the currency minor unit so they always add up
Table layout for desktop and a fieldset-per-line layout for narrow panels
Accessibility
Every input has a label: visible labels for the details, and visually hidden labels such as Unit price, line 2 inside the table, renumbered after each change. Remove buttons are named Remove line 2, focus moves to the next line or the Add line button after a removal, and totals are announced in a polite status region when a value is committed. All controls show a focus outline and transitions stop under prefers-reduced-motion.
Customise it
Change --ib-accent, --ib-field and --ib-soft on .ib for colour. Add currencies as options with a data-locale attribute; the script reads the hooks data-builder, data-lines, data-line, data-field, data-add, data-remove, data-currency, data-tax, data-discount and data-out, so it works with all three stylesheets.