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
.htmlfile (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
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.
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>
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.
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;
}
inputmode="numeric" so mobile keyboards still show digits.
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; }
The mortgage math
Now the engine. The fixed monthly payment for a mortgage is calculated with the standard amortization formula:
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;
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.
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();
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)
}
}
}
}
});
}
new Chart(...) on the same canvas without destroying the previous instance, you get ghost tooltips and memory leaks.
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);
}
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
localStorageso 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