← All gadgets

Mortgage Calculator

Payment, total interest, payoff date and a full amortization schedule

Running live from https://fun.whitneys.co, cross-origin and sandboxed.

Put it on your site

<iframe
  src="https://fun.whitneys.co/g/mortgage"
  width="360"
  height="620"
  title="Mortgage Calculator"
  sandbox="allow-scripts allow-popups-to-escape-sandbox"
  loading="lazy"
  style="border:0"></iframe>

An iframe, not a script tag -- so this gadget cannot touch the page you paste it into, and neither can we. Your settings above are baked into the URL.

Optional: let it resize itself to fit
<script>
addEventListener('message', function (e) {
  var d = e.data;
  if (!d || d.type !== 'fwf:height') return;
  if (typeof d.height !== 'number' || !isFinite(d.height)) return;
  document.querySelectorAll('iframe').forEach(function (f) {
    // Identity by source window, not by origin -- sandboxed frames report
    // their origin as "null". Do not "fix" this into an origin check.
    if (f.contentWindow !== e.source) return;
    f.style.height = Math.max(40, Math.min(2000, Math.ceil(d.height))) + 'px';
  });
});
</script>

The whole thing

Raw file

Every gadget is one file, and this is it -- all 15.6 kB. Take it, change it, ship it. Just keep the credit.

Show source
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<script type="application/json" id="gadget-manifest">
{
  "name": "Mortgage Calculator",
  "tagline": "Payment, total interest, payoff date and a full amortization schedule",
  "category": "tools",
  "tags": ["finance", "mortgage", "loan", "amortization"],
  "order": 8,
  "author": "rwhitney",
  "background": "dark",
  "size": { "w": 360, "h": 620, "resizable": true },
  "config": {
    "amount":   { "type": "range",  "label": "Loan amount", "min": 10000, "max": 2000000, "step": 5000, "default": 350000 },
    "rate":     { "type": "range",  "label": "Interest rate %", "min": 0, "max": 15, "step": 0.05, "default": 6.5 },
    "years":    { "type": "select", "label": "Term (years)", "options": ["10", "15", "20", "30"], "default": "30" },
    "extra":    { "type": "range",  "label": "Extra monthly", "min": 0, "max": 2000, "step": 25, "default": 0 },
    "currency": { "type": "text",   "label": "Currency symbol", "max": 3, "default": "$" },
    "accent":   { "type": "color",  "label": "Accent", "default": "#66D9EF" }
  },
  "permissions": []
}
</script>
<style>
  :root {
    --ink:#F8F8F2; --dim:#A59F85; --line:rgb(248 248 242 / .12);
    --panel:rgb(248 248 242 / .04);
    --int:#F92672;                 /* interest = pink */
    --prin:var(--accent, #66D9EF); /* principal = accent */
  }
  html, body {
    margin:0; background:transparent; color:var(--ink);
    font:14px/1.4 ui-sans-serif, system-ui, sans-serif;
    -webkit-text-size-adjust:100%;
  }
  #app { padding:14px; box-sizing:border-box; }

  h2 { font-size:10px; letter-spacing:.14em; text-transform:uppercase;
       color:var(--dim); margin:16px 0 8px; font-weight:700; }
  h2:first-child { margin-top:0; }

  /* ---- inputs ---------------------------------------------------------- */
  .grid { display:grid; grid-template-columns:1fr 1fr; gap:8px 10px; }
  label { display:block; font-size:11px; color:var(--dim); margin-bottom:3px; }
  .field { position:relative; }
  .field .pre {
    position:absolute; left:9px; top:50%; transform:translateY(-50%);
    color:var(--dim); font-size:13px; pointer-events:none;
  }
  input[type=number], select {
    width:100%; box-sizing:border-box;
    background:rgb(0 0 0 / .25); color:var(--ink);
    border:1px solid var(--line); border-radius:7px;
    padding:8px 9px; font:inherit; font-size:14px;
    -moz-appearance:textfield;
  }
  .field.money input { padding-left:20px; }
  input:focus, select:focus { outline:none; border-color:var(--accent, #66D9EF); }
  input::-webkit-outer-spin-button, input::-webkit-inner-spin-button {
    -webkit-appearance:none; margin:0;
  }
  select { cursor:pointer; }

  /* ---- headline result ------------------------------------------------- */
  #payment {
    background:var(--panel); border:1px solid var(--line); border-radius:10px;
    padding:12px 14px; margin-top:14px; text-align:center;
  }
  #payment .big {
    font:800 30px/1 ui-monospace, Menlo, Consolas, monospace;
    color:var(--accent, #66D9EF); font-variant-numeric:tabular-nums;
  }
  #payment .lab { font-size:11px; color:var(--dim); margin-top:5px;
                  letter-spacing:.1em; text-transform:uppercase; }

  .stats { display:grid; grid-template-columns:1fr 1fr; gap:1px;
           background:var(--line); border:1px solid var(--line);
           border-radius:9px; overflow:hidden; margin-top:10px; }
  .stat { background:#25261f; padding:9px 11px; }
  .stat .v { font:700 15px/1.1 ui-monospace, Menlo, monospace;
             font-variant-numeric:tabular-nums; }
  .stat .k { font-size:10px; color:var(--dim); margin-top:3px;
             text-transform:uppercase; letter-spacing:.06em; }
  .stat .v.int { color:var(--int); }

  /* ---- principal / interest split bar ---------------------------------- */
  #split { margin-top:12px; }
  #splitbar { height:12px; border-radius:6px; overflow:hidden; display:flex;
              border:1px solid var(--line); }
  #splitbar .p { background:var(--prin); }
  #splitbar .i { background:var(--int); }
  .legend { display:flex; gap:14px; margin-top:7px; font-size:11px; color:var(--dim); }
  .legend .sw { display:inline-block; width:9px; height:9px; border-radius:2px;
                margin-right:5px; vertical-align:-1px; }

  /* ---- balance chart --------------------------------------------------- */
  #chart { width:100%; height:66px; display:block; margin-top:6px;
           overflow:visible; }
  #chart .area { fill:var(--prin); opacity:.14; }
  #chart .line { fill:none; stroke:var(--prin); stroke-width:1.6;
                 vector-effect:non-scaling-stroke; }

  /* ---- schedule -------------------------------------------------------- */
  .segbtns { display:flex; gap:6px; margin-left:auto; }
  .segbtns button {
    font:inherit; font-size:11px; padding:3px 10px; border-radius:999px;
    border:1px solid var(--line); background:transparent; color:var(--dim);
    cursor:pointer;
  }
  .segbtns button.on { color:#1d1e18; background:var(--accent, #66D9EF);
                       border-color:var(--accent, #66D9EF); font-weight:700; }
  .rowhead { display:flex; align-items:center; }
  #tablewrap { max-height:260px; overflow-y:auto; border:1px solid var(--line);
               border-radius:9px; }
  table { width:100%; border-collapse:collapse;
          font:12px/1.3 ui-monospace, Menlo, Consolas, monospace;
          font-variant-numeric:tabular-nums; }
  thead th { position:sticky; top:0; background:#2b2c24; color:var(--dim);
             font-weight:700; font-size:10px; text-transform:uppercase;
             letter-spacing:.04em; padding:7px 8px; text-align:right; }
  thead th:first-child { text-align:left; }
  td { padding:6px 8px; text-align:right; border-top:1px solid rgb(248 248 242 / .06); }
  td:first-child { text-align:left; color:var(--dim); }
  td.int { color:var(--int); }
  td.prin { color:var(--prin); }
  tbody tr:hover { background:rgb(248 248 242 / .04); }
  .sr { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); }
</style>
</head>
<body>
<div id="app">
  <h2>Loan</h2>
  <div class="grid">
    <div>
      <label for="amount">Amount</label>
      <div class="field money"><span class="pre" id="sym">$</span>
        <input id="amount" type="number" min="0" step="1000" inputmode="decimal"></div>
    </div>
    <div>
      <label for="rate">Rate %</label>
      <div class="field"><input id="rate" type="number" min="0" max="99" step="0.01" inputmode="decimal"></div>
    </div>
    <div>
      <label for="years">Term (years)</label>
      <input id="years" type="number" min="1" max="50" step="1">
    </div>
    <div>
      <label for="extra">Extra / month</label>
      <div class="field money"><span class="pre">$</span>
        <input id="extra" type="number" min="0" step="25" inputmode="decimal"></div>
    </div>
  </div>

  <div id="payment">
    <div class="big" id="pay">--</div>
    <div class="lab">Monthly payment (principal &amp; interest)</div>
  </div>

  <div class="stats">
    <div class="stat"><div class="v int" id="totInt">--</div><div class="k">Total interest</div></div>
    <div class="stat"><div class="v" id="totPaid">--</div><div class="k">Total paid</div></div>
    <div class="stat"><div class="v" id="payoff">--</div><div class="k">Payoff date</div></div>
    <div class="stat"><div class="v" id="saved">--</div><div class="k">Time saved (extra)</div></div>
  </div>

  <div id="split">
    <h2>Where the money goes</h2>
    <div id="splitbar"><div class="p" id="barP"></div><div class="i" id="barI"></div></div>
    <div class="legend">
      <span><span class="sw" style="background:var(--prin)"></span><span id="legP">Principal</span></span>
      <span><span class="sw" style="background:var(--int)"></span><span id="legI">Interest</span></span>
    </div>
    <svg id="chart" viewBox="0 0 300 66" preserveAspectRatio="none" aria-hidden="true">
      <path class="area" id="chartArea"></path>
      <path class="line" id="chartLine"></path>
    </svg>
  </div>

  <div class="rowhead">
    <h2 style="margin-bottom:0">Amortization</h2>
    <div class="segbtns">
      <button id="segY" class="on" type="button">Yearly</button>
      <button id="segM" type="button">Monthly</button>
    </div>
  </div>
  <div id="tablewrap">
    <table>
      <thead><tr><th id="colFirst">Year</th><th>Principal</th><th>Interest</th><th>Balance</th></tr></thead>
      <tbody id="tbody"></tbody>
    </table>
  </div>
  <div class="sr" role="status" aria-live="polite" id="say"></div>
</div>

<script>
(function () {
  var cfg = window.gadget ? window.gadget.config : {};
  var sym = (cfg.currency || '$').slice(0, 3);
  document.documentElement.style.setProperty('--accent', cfg.accent || '#66D9EF');
  document.getElementById('sym').textContent = sym;
  [].forEach.call(document.querySelectorAll('.field.money .pre'), function (e) { e.textContent = sym; });

  var elAmount = document.getElementById('amount');
  var elRate = document.getElementById('rate');
  var elYears = document.getElementById('years');
  var elExtra = document.getElementById('extra');

  elAmount.value = numOr(cfg.amount, 350000);
  elRate.value = numOr(cfg.rate, 6.5);
  elYears.value = parseInt(cfg.years, 10) || 30;
  elExtra.value = numOr(cfg.extra, 0);

  function numOr(v, d) { return typeof v === 'number' && isFinite(v) ? v : d; }

  /* ---- money formatting ------------------------------------------------ */
  function money(x, dp) {
    if (!isFinite(x)) return '--';
    return sym + x.toLocaleString(undefined, {
      minimumFractionDigits: dp || 0, maximumFractionDigits: dp || 0
    });
  }

  /* ---- the amortization engine ----------------------------------------
     Standard fixed-rate amortization. Two things people get wrong:
       1. rate 0 -- the M = P·r(1+r)^n/((1+r)^n-1) formula divides by zero,
          so a no-interest loan is just principal / months.
       2. extra payments -- they do not change the scheduled payment, they
          shorten the TERM. So the schedule is simulated month by month until
          the balance clears, which is also what produces the payoff date and
          the time saved.
     Everything is computed at full precision; only the display rounds. The
     final payment is trimmed so the balance lands exactly on zero rather than
     a few cents over. */
  function amortize(principal, annualRate, months, extra) {
    var r = annualRate / 100 / 12;
    var scheduled = r === 0
      ? principal / months
      : principal * r * Math.pow(1 + r, months) / (Math.pow(1 + r, months) - 1);

    var rows = [], bal = principal, totalInterest = 0, m = 0;
    var cap = months * 3 + 12;               // safety bound
    while (bal > 0.005 && m < cap) {
      m++;
      var interest = bal * r;
      var pay = scheduled + extra;
      var toPrincipal = pay - interest;
      if (toPrincipal >= bal) {              // final (partial) payment
        toPrincipal = bal;
        pay = toPrincipal + interest;
      }
      bal -= toPrincipal;
      totalInterest += interest;
      rows.push({ m: m, pay: pay, principal: toPrincipal, interest: interest, balance: bal });
    }
    return { scheduled: scheduled, rows: rows, totalInterest: totalInterest,
             months: rows.length, principal: principal };
  }

  /* ---- rendering ------------------------------------------------------- */
  var view = 'year';
  var current = null;

  function monthName(offset) {
    var d = new Date();
    d.setDate(1);
    d.setMonth(d.getMonth() + offset);
    return d.toLocaleDateString(undefined, { month: 'short', year: 'numeric' });
  }

  function recompute() {
    var P = Math.max(0, parseFloat(elAmount.value) || 0);
    var rate = Math.max(0, parseFloat(elRate.value) || 0);
    var yrs = Math.min(50, Math.max(1, parseInt(elYears.value, 10) || 30));
    var extra = Math.max(0, parseFloat(elExtra.value) || 0);
    var n = yrs * 12;

    var base = amortize(P, rate, n, 0);        // no extra -- for the baseline term
    var run = amortize(P, rate, n, extra);     // with extra -- what actually happens
    current = run;

    var totalPaid = P + run.totalInterest;
    document.getElementById('pay').textContent = money(base.scheduled, 2);
    document.getElementById('totInt').textContent = money(run.totalInterest);
    document.getElementById('totPaid').textContent = money(totalPaid);
    document.getElementById('payoff').textContent = run.months ? monthName(run.months) : '--';

    var savedMonths = base.months - run.months;
    document.getElementById('saved').textContent =
      savedMonths > 0 ? (Math.floor(savedMonths / 12) + 'y ' + (savedMonths % 12) + 'm') : '--';

    // split bar
    var totalP = P, totalI = run.totalInterest, sum = totalP + totalI || 1;
    document.getElementById('barP').style.width = (100 * totalP / sum) + '%';
    document.getElementById('barI').style.width = (100 * totalI / sum) + '%';
    document.getElementById('legP').textContent = 'Principal ' + Math.round(100 * totalP / sum) + '%';
    document.getElementById('legI').textContent = 'Interest ' + Math.round(100 * totalI / sum) + '%';

    drawChart(run.rows, P);
    renderTable();
    document.getElementById('say').textContent =
      'Monthly payment ' + money(base.scheduled, 2) + ', total interest ' + money(run.totalInterest);
  }

  function drawChart(rows, P) {
    var W = 300, H = 66, n = rows.length;
    if (!n || P <= 0) {
      document.getElementById('chartLine').setAttribute('d', '');
      document.getElementById('chartArea').setAttribute('d', '');
      return;
    }
    var d = 'M0 ' + (H - (rows[0].balance / P) * H).toFixed(2);
    for (var i = 0; i < n; i++) {
      var x = (i / (n - 1 || 1)) * W;
      var y = H - (rows[i].balance / P) * H;
      d += ' L' + x.toFixed(2) + ' ' + y.toFixed(2);
    }
    document.getElementById('chartLine').setAttribute('d', d);
    document.getElementById('chartArea').setAttribute('d', d + ' L' + W + ' ' + H + ' L0 ' + H + ' Z');
  }

  function renderTable() {
    var tb = document.getElementById('tbody');
    tb.innerHTML = '';
    if (!current || !current.rows.length) return;

    var rows;
    if (view === 'month') {
      rows = current.rows.map(function (r) {
        return { label: monthName(r.m), principal: r.principal, interest: r.interest, balance: r.balance };
      });
      document.getElementById('colFirst').textContent = 'Month';
    } else {
      // Group into calendar-ish years of 12 payments.
      var years = [], acc = null;
      current.rows.forEach(function (r, idx) {
        if (idx % 12 === 0) {
          acc = { label: 'Year ' + (years.length + 1), principal: 0, interest: 0, balance: r.balance };
          years.push(acc);
        }
        acc.principal += r.principal;
        acc.interest += r.interest;
        acc.balance = r.balance;
      });
      rows = years;
      document.getElementById('colFirst').textContent = 'Year';
    }

    var frag = document.createDocumentFragment();
    rows.forEach(function (r) {
      var tr = document.createElement('tr');
      tr.innerHTML =
        '<td>' + r.label + '</td>' +
        '<td class="prin">' + money(r.principal) + '</td>' +
        '<td class="int">' + money(r.interest) + '</td>' +
        '<td>' + money(r.balance) + '</td>';
      frag.appendChild(tr);
    });
    tb.appendChild(frag);
  }

  /* ---- wiring ---------------------------------------------------------- */
  [elAmount, elRate, elYears, elExtra].forEach(function (el) {
    el.addEventListener('input', recompute);
  });

  var segY = document.getElementById('segY'), segM = document.getElementById('segM');
  segY.addEventListener('click', function () {
    view = 'year'; segY.classList.add('on'); segM.classList.remove('on'); renderTable();
  });
  segM.addEventListener('click', function () {
    view = 'month'; segM.classList.add('on'); segY.classList.remove('on'); renderTable();
  });

  recompute();
})();
</script>
</body>
</html>