mortcalc: Amortization Table That Beat the Spreadsheet for Client Demos
Real estate landing page needed monthly payment + amortization without embedding a third-party iframe. Pure math, printable table.
Use case: Static site for a mortgage broker. Calculator drives email capture — but math must be correct first.
Full calculator
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Mortgage Calculator</title>
<style>
body { font-family: system-ui; max-width: 720px; margin: 2rem auto; padding: 0 1rem; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
label { display: block; font-size: 14px; margin-bottom: .25rem; }
input { width: 100%; padding: .4rem; }
table { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 1rem; }
th, td { border: 1px solid #e2e8f0; padding: .35rem .5rem; text-align: right; }
th:first-child, td:first-child { text-align: left; }
</style>
</head>
<body>
<h1>Mortgage Calculator</h1>
<div class="row">
<div><label>Loan amount ($)</label><input type="number" id="principal" value="350000"></div>
<div><label>Annual rate (%)</label><input type="number" id="rate" value="6.5" step="0.01"></div>
<div><label>Term (years)</label><input type="number" id="years" value="30"></div>
<div><label>Extra monthly ($)</label><input type="number" id="extra" value="0"></div>
</div>
<button id="go" style="margin-top:1rem">Calculate</button>
<p id="summary"></p>
<table id="table" hidden><thead><tr><th>#</th><th>Payment</th><th>Principal</th><th>Interest</th><th>Balance</th></tr></thead><tbody></tbody></table>
<script>
document.getElementById('go').onclick = () => {
const P = Number(document.getElementById('principal').value);
const annual = Number(document.getElementById('rate').value) / 100;
const years = Number(document.getElementById('years').value);
const extra = Number(document.getElementById('extra').value) || 0;
const n = years * 12;
const r = annual / 12;
if (!P || !years || r < 0) return;
const payment = r === 0 ? P / n : (P * r * Math.pow(1 + r, n)) / (Math.pow(1 + r, n) - 1);
let balance = P;
const tbody = document.querySelector('#table tbody');
tbody.innerHTML = '';
let totalInterest = 0;
for (let i = 1; i <= n && balance > 0.01; i++) {
const interest = balance * r;
let principalPaid = payment - interest + extra;
if (principalPaid > balance) principalPaid = balance;
balance -= principalPaid;
totalInterest += interest;
const tr = document.createElement('tr');
tr.innerHTML = '<td>' + i + '</td><td>' + (principalPaid + interest).toFixed(2) + '</td><td>' + principalPaid.toFixed(2) + '</td><td>' + interest.toFixed(2) + '</td><td>' + Math.max(0, balance).toFixed(2) + '</td>';
tbody.appendChild(tr);
}
document.getElementById('summary').textContent =
'Monthly payment: $' + payment.toFixed(2) + (extra ? ' + $' + extra.toFixed(2) + ' extra' : '') +
' | Total interest: $' + totalInterest.toFixed(2);
document.getElementById('table').hidden = false;
};
</script>
</body>
</html>
Off-by-one on last payment
Cap principalPaid at remaining balance — prevents negative balance row.
30-year table DOM size
360 rows is fine. For print, add show first 12 months toggle to reduce scroll on mobile.
Verify
$350k @ 6.5% / 30yr → payment ≈ $2,212 (compare bankrate.com) Extra $200 → fewer rows, lower final balance