← JS Examples
A Builder's Manual · The Principal Quarterly
View Demo ›
From First Line to Final Calculation

Building The Mortgage

A step-by-step manual for constructing a polished mortgage calculator in plain HTML, CSS, and JavaScript — no frameworks required.
Chapter 01

The blueprint

Before writing code, sketch what you're building. The calculator is a single-page app with two columns: a sticky input panel on the left, and a results pane on the right. The results pane contains four sections — a headline figure, a stats row, a chart, and an amortization table.

What you'll need

  • A single .html file (everything lives here)
  • Chart.js via CDN for the balance chart
  • Two Google Fonts: Fraunces (serif display) and JetBrains Mono (labels)
  • No build tools, no frameworks, no npm
Design principle Treat this like an editorial spread, not a SaaS form. The big numbers are the hero. Labels are quiet, type is generous, and there's exactly one accent color earning its keep.

The folder structure

It's just one file. Create mortgage.html and open it in your browser. That's the entire toolchain.

my-mortgage-app/
└── mortgage.html   ← everything goes here

# That's it. No npm install. No webpack.
# Open the file in any browser to run it.
Chapter 02

Scaffolding the HTML

Start with the document skeleton. Load fonts in the <head> and pull in Chart.js right after, so it's ready when our script runs at the bottom of the body.

The body is divided into a masthead, a title block, a two-column grid (controls + results), and a colophon. Each section is independent and styled in isolation.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>The Mortgage — A Calculator</title>

  <!-- Fonts -->
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300;0,9..144,400;1,9..144,400&family=JetBrains+Mono:wght@400;500;600&family=Inter+Tight:wght@400;500;600&display=swap" rel="stylesheet">

  <!-- Chart.js -->
  <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.0/chart.umd.min.js"></script>

  <style>
    /* CSS goes here (Chapter 03) */
  </style>
</head>
<body>
  <div class="frame">

    <!-- Masthead -->
    <div class="masthead">...</div>

    <!-- Title -->
    <div class="title-block">...</div>

    <!-- Two-column grid -->
    <div class="grid">
      <aside class="controls">...</aside>
      <main class="results-pane">...</main>
    </div>

    <!-- Colophon -->
    <div class="colophon">...</div>

  </div>

  <script>
    /* JavaScript goes here (Chapter 06+) */
  </script>
</body>
</html>
Why a single file? For a project this size, splitting into separate CSS/JS files adds friction without benefit. Everything is one Ctrl+F away, and you can email it, host it anywhere, or open it from your desktop.
Chapter 03

The design system

Before styling components, define the variables. A small, opinionated palette beats a sprawling one — six colors plus two neutrals is plenty. The single accent (#B8412C, oxblood) does all the heavy lifting.

CSS custom properties

:root {
  /* Backgrounds */
  --bg: #F4EFE6;          /* warm paper */
  --bg-card: #FBF8F2;     /* slightly lighter for cards */

  /* Text */
  --ink: #1A1814;         /* near-black, warmer than #000 */
  --ink-soft: #4A453D;
  --ink-muted: #8C8578;

  /* Accent */
  --accent: #B8412C;      /* oxblood */
  --accent-deep: #8A2F1F;
  --accent-soft: #E8D5CC;

  /* Lines */
  --rule: #1A1814;        /* hard rules */
  --grid: #D9D2C4;        /* soft dividers */
}
* { box-sizing: border-box; margin: 0; padding: 0; }

html, body {
  background: var(--bg);
  color: var(--ink);
  font-family: 'Inter Tight', sans-serif;
  font-size: 15px;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}

body {
  /* Subtle dot grid background gives texture */
  background-image:
    radial-gradient(circle at 1px 1px,
      rgba(26,24,20,0.04) 1px, transparent 0);
  background-size: 24px 24px;
  min-height: 100vh;
  padding: 32px 24px 80px;
}

.frame {
  max-width: 1240px;
  margin: 0 auto;
}

The typography pairing

Fraunces for display — it's a variable serif with an optical-size axis, meaning the big headlines automatically use the display-cut letterforms. Inter Tight for body, and JetBrains Mono for tiny uppercase labels. That's the entire type system.

Rule of three Three fonts, three sizes of label, three accent shades. Constraint is what makes the design feel intentional rather than improvised.
Chapter 04

Building the input panel

The left column is a sticky panel with seven inputs: home price, down payment, interest rate, term, extra payment, property tax, and home insurance. Each field has a tiny mono label and a generous serif input — the inputs should feel like reading, not filling out a form.

The HTML

<aside class="controls">
  <div class="section-label">
    <span>§ Parameters</span>
    <span>Live</span>
  </div>

  <div class="field">
    <label for="homePrice">
      Home Price <span class="hint">total</span>
    </label>
    <div class="input-wrap has-prefix">
      <span class="prefix">$</span>
      <input type="text" id="homePrice" value="525,000" inputmode="numeric">
    </div>
  </div>

  <div class="field">
    <label for="rate">
      Interest Rate <span class="hint">APR</span>
    </label>
    <div class="input-wrap has-suffix">
      <input type="text" id="rate" value="6.75" inputmode="decimal">
      <span class="suffix">%</span>
    </div>
  </div>

  <div class="field">
    <label for="term">Loan Term</label>
    <select id="term">
      <option value="15">15 years</option>
      <option value="20">20 years</option>
      <option value="30" selected>30 years</option>
    </select>
  </div>

  <!-- ... more fields: downPayment, extra, taxes, insurance -->
</aside>
.controls {
  background: var(--bg-card);
  border: 1px solid var(--rule);
  padding: 32px 28px;
  position: sticky;
  top: 24px;
}

.field { margin-bottom: 22px; }

.field label {
  display: flex;
  justify-content: space-between;
  font-family: 'JetBrains Mono', monospace;
  font-size: 10px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--ink-soft);
  margin-bottom: 8px;
}

.input-wrap { position: relative; }

.input-wrap .prefix,
.input-wrap .suffix {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  font-family: 'Fraunces', serif;
  font-size: 22px;
  color: var(--ink-muted);
  pointer-events: none;
}

.input-wrap .prefix { left: 14px; }
.input-wrap .suffix { right: 14px; }

input[type="text"], select {
  width: 100%;
  background: transparent;
  border: 1px solid var(--grid);
  border-radius: 0;       /* no rounded corners — keep it editorial */
  padding: 12px 14px;
  font-family: 'Fraunces', serif;
  font-size: 22px;
  color: var(--ink);
}

.input-wrap.has-prefix input { padding-left: 30px; }

input:focus, select:focus {
  outline: none;
  border-color: var(--accent);
  background: #fff;
}
Why type="text" and not type="number"? Number inputs strip commas. Since we want to display "525,000" with formatting, we use text inputs and set inputmode="numeric" so mobile keyboards still show digits.
Chapter 05

Headline & stats

The right column opens with the hero result — one enormous serif number for the all-in monthly payment, accompanied by a small circular "term" seal. Underneath, a three-column stats row breaks out the loan amount, total interest, and payoff date.

The headline result

<div class="headline-result">
  <div>
    <div class="label">— Monthly Payment, All-In —</div>
    <div class="figure">
      <span id="monthlyMain">$2,724</span><span class="cents" id="monthlyCents">.18</span>
      <span class="unit">Principal · Interest · Taxes · Insurance</span>
    </div>
  </div>
  <div class="seal">
    <div class="seal-inner">
      <div>Term</div>
      <div class="term" id="sealTerm">30</div>
      <div>Years</div>
    </div>
  </div>
</div>

<div class="stats-row">
  <div class="stat">
    <div class="stat-label">Loan Amount</div>
    <div class="stat-value" id="loanAmount">$420,000</div>
    <div class="stat-detail" id="loanDetail">80.0% LTV</div>
  </div>
  <div class="stat">
    <div class="stat-label">Total Interest</div>
    <div class="stat-value" id="totalInterest">$560,617</div>
    <div class="stat-detail" id="interestDetail">133.5% of principal</div>
  </div>
  <div class="stat">
    <div class="stat-label">Payoff Date</div>
    <div class="stat-value" id="payoffDate">May 2056</div>
    <div class="stat-detail" id="payoffDetail">360 payments</div>
  </div>
</div>
.headline-result {
  background: var(--bg-card);
  border: 1px solid var(--rule);
  padding: 36px 32px;
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: end;
  gap: 24px;
  position: relative;
  overflow: hidden;
}

/* Oxblood accent strip on the left edge */
.headline-result::before {
  content: '';
  position: absolute;
  top: 0; left: 0;
  width: 6px;
  height: 100%;
  background: var(--accent);
}

.headline-result .figure {
  font-family: 'Fraunces', serif;
  font-weight: 300;
  font-size: clamp(48px, 7vw, 84px);
  line-height: 1;
  letter-spacing: -0.035em;
}

.headline-result .figure .cents {
  font-size: 0.45em;          /* superscript cents */
  color: var(--ink-muted);
  vertical-align: top;
}

/* The wax-seal term badge */
.seal {
  width: 88px;
  height: 88px;
  border: 1px solid var(--ink);
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
}

.seal-inner {
  width: 76px;
  height: 76px;
  border: 1px solid var(--ink);
  border-radius: 50%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}

.stats-row {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  border: 1px solid var(--rule);
  background: var(--bg-card);
}

.stat {
  padding: 24px 22px;
  border-right: 1px solid var(--grid);
}

.stat:last-child { border-right: none; }
Chapter 06

The mortgage math

Now the engine. The fixed monthly payment for a mortgage is calculated with the standard amortization formula:

Monthly payment
M = P · r(1 + r)n (1 + r)n − 1

Where P is the principal (loan amount), r is the monthly interest rate (annual rate ÷ 12 ÷ 100), and n is the number of monthly payments (years × 12).

The calculate function

This single function does everything: parses inputs, computes the payment, then walks month-by-month to build the amortization schedule (factoring in any extra monthly payment).

function calculate() {
  // 1. Parse all inputs (parseNum strips commas)
  const price  = parseNum(document.getElementById('homePrice').value);
  const down   = parseNum(document.getElementById('downPayment').value);
  const rate   = parseFloat(document.getElementById('rate').value) || 0;
  const years  = parseInt(document.getElementById('term').value);
  const extra  = parseNum(document.getElementById('extra').value);
  const taxes  = parseNum(document.getElementById('taxes').value);
  const ins    = parseNum(document.getElementById('insurance').value);

  // 2. Derived values
  const principal = Math.max(price - down, 0);
  const r = rate / 100 / 12;        // monthly rate
  const n = years * 12;             // total months

  // 3. Standard mortgage payment formula
  let payment = 0;
  if (principal > 0 && n > 0) {
    payment = r === 0
      ? principal / n
      : principal * (r * Math.pow(1+r, n)) / (Math.pow(1+r, n) - 1);
  }

  // 4. Build amortization schedule (handles extra payments)
  const schedule = [];
  let balance = principal;
  let totalInterest = 0;
  let month = 0;
  const maxMonths = n + 12; // safety cap

  while (balance > 0.01 && month < maxMonths) {
    const interest = balance * r;
    let principalPay = payment - interest + extra;
    if (principalPay > balance) principalPay = balance;
    const totalPay = principalPay + interest;
    balance -= principalPay;
    totalInterest += interest;
    month++;
    schedule.push({
      month, payment: totalPay,
      principal: principalPay,
      interest,
      balance: Math.max(balance, 0)
    });
    if (payment === 0) break;
  }

  // 5. Tax + insurance as monthly figures
  const taxMonthly = taxes / 12;
  const insMonthly = ins / 12;
  const allInMonthly = payment + extra + taxMonthly + insMonthly;

  return {
    price, down, principal, payment, extra,
    taxMonthly, insMonthly, allInMonthly,
    schedule, totalInterest, years, rate
  };
}
// ===== Number utilities =====

// Format integer with thousand separators: 1234567 → "1,234,567"
const fmt = n => n.toLocaleString('en-US', { maximumFractionDigits: 0 });

// Format with 2 decimals: 1234.5 → "1,234.50"
const fmt2 = n => n.toLocaleString('en-US', {
  minimumFractionDigits: 2,
  maximumFractionDigits: 2
});

// Extract just the cents portion: 2724.18 → ".18"
const cents = n => (n - Math.floor(n)).toFixed(2).substring(1);

// Strip everything but digits and dot: "$525,000" → 525000
const parseNum = v => Number(String(v).replace(/[^0-9.]/g, '')) || 0;
The "extra payment" trick The loop adds extra to each principal payment. Since the loan amortizes faster, the loop terminates early — giving you both the new payoff date and total interest savings for free.
Chapter 07

Rendering results

The recalc() function is the heart of the live-updating UI. It calls calculate(), then writes the results into the DOM. Wire it to every input's input event and the whole calculator updates as the user types.

let lastSchedule = [];

function recalc() {
  const c = calculate();
  lastSchedule = c.schedule;

  // --- Headline ---
  document.getElementById('monthlyMain').textContent  = '$' + fmt(Math.floor(c.allInMonthly));
  document.getElementById('monthlyCents').textContent = cents(c.allInMonthly);
  document.getElementById('sealTerm').textContent     = c.years;

  // --- Stats: Loan Amount ---
  document.getElementById('loanAmount').textContent = '$' + fmt(c.principal);
  const ltv = c.price > 0 ? (c.principal / c.price * 100) : 0;
  document.getElementById('loanDetail').textContent = ltv.toFixed(1) + '% LTV';

  // --- Stats: Total Interest ---
  document.getElementById('totalInterest').textContent = '$' + fmt(c.totalInterest);
  const intPct = c.principal > 0 ? (c.totalInterest / c.principal * 100) : 0;
  document.getElementById('interestDetail').textContent = intPct.toFixed(1) + '% of principal';

  // --- Stats: Payoff Date ---
  const months = c.schedule.length;
  if (months > 0) {
    const payoff = new Date();
    payoff.setMonth(payoff.getMonth() + months);
    const monthName = ['Jan','Feb','Mar','Apr','May','Jun',
                       'Jul','Aug','Sep','Oct','Nov','Dec'][payoff.getMonth()];
    document.getElementById('payoffDate').textContent =
      `${monthName} ${payoff.getFullYear()}`;
    document.getElementById('payoffDetail').textContent =
      `${months} payments` +
      (c.extra > 0 ? ` · ${c.years*12 - months} saved` : '');
  }

  renderChart(c);    // (Chapter 08)
  renderTable();     // (Chapter 09)
}
// Auto-format currency fields on blur, recalc on every input
['homePrice','downPayment','extra','taxes','insurance'].forEach(id => {
  const el = document.getElementById(id);
  el.addEventListener('blur',  () => {
    el.value = fmt(parseNum(el.value));  // reformat "525000" → "525,000"
    recalc();
  });
  el.addEventListener('input', recalc);
});

document.getElementById('rate').addEventListener('input', recalc);
document.getElementById('term').addEventListener('change', recalc);

// Initial render
recalc();
Chapter 08

The amortization chart

Chart.js handles the heavy lifting. We aggregate the monthly schedule into years, then plot two filled line series: the declining balance (black) and cumulative interest paid (oxblood). The visual story — interest eating most of the early payments — becomes immediately obvious.

Aggregating by year

function aggregateByYear(schedule) {
  const years = [];
  let acc = null;
  let cumInterest = 0;
  let currentYear = null;

  schedule.forEach((row, i) => {
    const yearIdx = Math.floor(i / 12) + 1;
    if (yearIdx !== currentYear) {
      if (acc) years.push(acc);
      currentYear = yearIdx;
      acc = { year: yearIdx, payment: 0, principal: 0,
              interest: 0, endBalance: 0, cumInterest: 0 };
    }
    acc.payment    += row.payment;
    acc.principal  += row.principal;
    acc.interest   += row.interest;
    acc.endBalance  = row.balance;        // last month's balance = year end
    cumInterest    += row.interest;
    acc.cumInterest = cumInterest;
  });
  if (acc) years.push(acc);
  return years;
}
let chart;

function renderChart(c) {
  const yearly = aggregateByYear(c.schedule);
  const labels       = yearly.map(y => `Yr ${y.year}`);
  const balances     = yearly.map(y => y.endBalance);
  const cumInterest  = yearly.map(y => y.cumInterest);

  const ctx = document.getElementById('chart').getContext('2d');
  if (chart) chart.destroy();           // destroy previous instance

  chart = new Chart(ctx, {
    type: 'line',
    data: {
      labels,
      datasets: [
        {
          label: 'Balance',
          data: balances,
          borderColor: '#1A1814',
          backgroundColor: 'rgba(26,24,20,0.06)',
          borderWidth: 2,
          fill: true,
          tension: 0.35,
          pointRadius: 0
        },
        {
          label: 'Interest Paid',
          data: cumInterest,
          borderColor: '#B8412C',
          backgroundColor: 'rgba(184,65,44,0.08)',
          borderWidth: 2,
          fill: true,
          tension: 0.35,
          pointRadius: 0
        }
      ]
    },
    options: {
      responsive: true,
      maintainAspectRatio: false,
      interaction: { mode: 'index', intersect: false },
      plugins: {
        legend: { display: false },     // we use custom HTML legend
        tooltip: {
          backgroundColor: '#1A1814',
          callbacks: {
            label: ctx => ` ${ctx.dataset.label}:  $${fmt(ctx.parsed.y)}`
          }
        }
      },
      scales: {
        y: {
          ticks: {
            callback: v => '$' + (v >= 1000 ? (v/1000).toFixed(0)+'k' : v)
          }
        }
      }
    }
  });
}
Always destroy before recreating Chart.js attaches event listeners to the canvas. If you call new Chart(...) on the same canvas without destroying the previous instance, you get ghost tooltips and memory leaks.
Chapter 09

The schedule table

The amortization table has two views — yearly summary and full monthly detail. A toggle button group switches between them. Each row gets an inline balance bar (a thin colored strip) so you can see the loan paying down at a glance.

let viewMode = 'yearly';

function renderTable() {
  const body = document.getElementById('amortBody');
  const periodCol = document.getElementById('periodCol');
  body.innerHTML = '';
  if (lastSchedule.length === 0) return;

  const maxBalance = lastSchedule[0]?.balance + lastSchedule[0]?.principal || 1;

  if (viewMode === 'yearly') {
    periodCol.textContent = 'Year';
    const years = aggregateByYear(lastSchedule);
    years.forEach(y => {
      const pct = (y.endBalance / maxBalance) * 100;
      const tr = document.createElement('tr');
      tr.className = 'year-row';
      tr.innerHTML = `
        <td>Year ${y.year}</td>
        <td>$${fmt2(y.payment)}</td>
        <td>$${fmt2(y.principal)}</td>
        <td>$${fmt2(y.interest)}</td>
        <td>$${fmt2(y.endBalance)}
          <span class="balance-bar" style="--w:${pct}%"></span>
        </td>`;
      body.appendChild(tr);
    });
  } else {
    periodCol.textContent = 'Month';
    lastSchedule.forEach(row => {
      const pct = (row.balance / maxBalance) * 100;
      const tr = document.createElement('tr');
      tr.innerHTML = `
        <td>${row.month}</td>
        <td>$${fmt2(row.payment)}</td>
        <td>$${fmt2(row.principal)}</td>
        <td>$${fmt2(row.interest)}</td>
        <td>$${fmt2(row.balance)}
          <span class="balance-bar" style="--w:${pct}%"></span>
        </td>`;
      body.appendChild(tr);
    });
  }
}
document.getElementById('viewYearly').addEventListener('click', () => {
  viewMode = 'yearly';
  document.getElementById('viewYearly').classList.add('active');
  document.getElementById('viewMonthly').classList.remove('active');
  renderTable();
});

document.getElementById('viewMonthly').addEventListener('click', () => {
  viewMode = 'monthly';
  document.getElementById('viewMonthly').classList.add('active');
  document.getElementById('viewYearly').classList.remove('active');
  renderTable();
});
/* Balance bar — CSS custom property drives the width */
.balance-bar {
  display: inline-block;
  width: 60px;
  height: 6px;
  background: var(--grid);
  margin-left: 10px;
  vertical-align: middle;
  position: relative;
}

.balance-bar::after {
  content: '';
  position: absolute;
  top: 0; left: 0;
  height: 100%;
  background: var(--accent);
  width: var(--w, 100%);     /* set via inline style="--w:75%" */
}

/* Sticky header inside scrollable container */
.table-scroll { max-height: 480px; overflow-y: auto; }

thead th {
  position: sticky;
  top: 0;
  background: var(--bg-card);
  border-bottom: 1px solid var(--rule);
}
Chapter 10

Polish & ship

The final touches separate a working calculator from a memorable one.

Staggered entrance animation

Each section fades up with a slight delay, so the page assembles itself in front of the reader rather than appearing all at once.

@keyframes fadeUp {
  from { opacity: 0; transform: translateY(8px); }
  to   { opacity: 1; transform: translateY(0); }
}

.title-block,
.controls,
.headline-result,
.stats-row,
.chart-card,
.table-card {
  animation: fadeUp 0.6s ease-out both;
}

/* Stagger the delays so sections appear in sequence */
.controls         { animation-delay: 0.05s; }
.headline-result  { animation-delay: 0.10s; }
.stats-row        { animation-delay: 0.15s; }
.chart-card       { animation-delay: 0.20s; }
.table-card       { animation-delay: 0.25s; }
/* Stack columns on smaller screens */
@media (max-width: 920px) {
  .grid {
    grid-template-columns: 1fr;
    gap: 32px;
  }
  /* Sticky position breaks the stacked layout —
     let the controls flow normally on mobile */
  .controls { position: static; }
}

@media (max-width: 640px) {
  .stats-row { grid-template-columns: 1fr; }
  .stat {
    border-right: none;
    border-bottom: 1px solid var(--grid);
  }
  .stat:last-child { border-bottom: none; }
}

Ideas for what to add next

  • PMI auto-calc when the down payment drops below 20% (typically ~0.5–1% of loan annually)
  • HOA fees as another monthly addition to the all-in figure
  • Side-by-side comparison of two loan scenarios — useful for refinance decisions
  • Save scenarios using localStorage so users can revisit favorites
  • Print stylesheet that lays the whole page out as a clean PDF-ready document
  • Sharable URL by encoding parameters as query strings
You're done One HTML file. No build step. No framework. A calculator that's actually pleasant to use, that can be deployed by uploading a single file — exactly the kind of self-contained tool the web does best.
Set in Fraunces, Inter Tight & JetBrains Mono
A Builder's Manual · No frameworks were harmed