Real Estate — Affordability Estimator
An editorial home-affordability estimator that turns annual income, monthly debts, down payment, interest rate, loan term and a debt-to-income target into a recommended purchase-price range. A live semicircular gauge tracks the maximum price, while result cards break out the estimated monthly housing payment, loan amount, required income and down-payment share. Matching sample listings flag whether each fictional home fits the buyer's band, with a per-home monthly payment preview.
MCP
Код
:root {
--ivory: #f7f4ec;
--paper: #fffdf8;
--white: #ffffff;
--green: #1f3d34;
--green-d: #16302a;
--green-700: #26493e;
--green-50: #e8efea;
--brass: #b08d57;
--brass-d: #94733f;
--brass-50: #f3ead9;
--ink: #1c2a25;
--ink-2: #33433d;
--muted: #6b7a72;
--line: rgba(31, 61, 52, 0.12);
--line-2: rgba(31, 61, 52, 0.22);
--ok: #2f9e6f;
--warn: #c98a2b;
--danger: #c4503e;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 22px;
--shadow-sm: 0 1px 2px rgba(28, 42, 37, 0.06), 0 2px 8px rgba(28, 42, 37, 0.05);
--shadow-md: 0 10px 30px -12px rgba(22, 48, 42, 0.28), 0 2px 6px rgba(22, 48, 42, 0.08);
--shadow-lg: 0 30px 60px -24px rgba(22, 48, 42, 0.4);
}
* {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
font-family: "Inter", system-ui, sans-serif;
line-height: 1.55;
color: var(--ink);
background-color: var(--ivory);
background-image:
radial-gradient(1200px 600px at 110% -10%, rgba(176, 141, 87, 0.08), transparent 60%),
radial-gradient(900px 500px at -10% 0%, rgba(31, 61, 52, 0.06), transparent 55%);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3 {
font-family: "Cormorant Garamond", Georgia, serif;
font-weight: 600;
letter-spacing: 0.2px;
margin: 0;
}
.page {
max-width: 1180px;
margin: 0 auto;
padding: 28px 24px 48px;
}
/* ============ MASTHEAD ============ */
.masthead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding-bottom: 22px;
border-bottom: 1px solid var(--line);
}
.brandmark {
display: flex;
align-items: center;
gap: 14px;
}
.brand-glyph {
display: grid;
place-items: center;
width: 50px;
height: 50px;
border-radius: var(--r-md);
background: linear-gradient(155deg, var(--green-700), var(--green-d));
color: var(--brass-50);
font-family: "Cormorant Garamond", serif;
font-weight: 700;
font-size: 17px;
letter-spacing: 0.5px;
box-shadow: var(--shadow-sm), inset 0 0 0 1px rgba(176, 141, 87, 0.35);
}
.brand-name {
margin: 0;
font-family: "Cormorant Garamond", serif;
font-weight: 700;
font-size: 22px;
color: var(--green-d);
line-height: 1.1;
}
.brand-tag {
margin: 2px 0 0;
font-size: 11px;
letter-spacing: 1.6px;
text-transform: uppercase;
color: var(--muted);
}
.masthead-nav {
display: flex;
align-items: center;
gap: 20px;
}
.nav-link {
font-size: 14px;
font-weight: 500;
color: var(--ink-2);
text-decoration: none;
padding: 4px 2px;
border-bottom: 1.5px solid transparent;
transition: color 0.18s ease, border-color 0.18s ease;
}
.nav-link:hover {
color: var(--green);
}
.nav-link.is-active {
color: var(--green-d);
border-color: var(--brass);
}
.nav-pill {
font-size: 12px;
font-weight: 500;
color: var(--brass-d);
background: var(--brass-50);
border: 1px solid rgba(176, 141, 87, 0.3);
padding: 6px 12px;
border-radius: 999px;
}
/* ============ INTRO ============ */
.intro {
margin: 40px 0 28px;
max-width: 760px;
}
.eyebrow {
margin: 0 0 10px;
font-size: 12px;
font-weight: 600;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--brass-d);
}
.display {
font-size: clamp(32px, 5vw, 50px);
line-height: 1.05;
font-weight: 600;
color: var(--green-d);
max-width: 14ch;
}
.lede {
margin: 16px 0 0;
font-size: 16px;
color: var(--ink-2);
max-width: 58ch;
}
/* ============ GRID ============ */
.grid {
display: grid;
grid-template-columns: 1.05fr 1fr;
gap: 24px;
align-items: start;
}
.panel {
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 26px;
box-shadow: var(--shadow-md);
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 20px;
}
.panel-title {
font-size: 26px;
color: var(--green-d);
}
.ghost-btn {
font-family: inherit;
font-size: 13px;
font-weight: 500;
color: var(--muted);
background: transparent;
border: 1px solid var(--line-2);
padding: 6px 14px;
border-radius: 999px;
cursor: pointer;
transition: all 0.18s ease;
}
.ghost-btn:hover {
color: var(--green-d);
border-color: var(--brass);
background: var(--brass-50);
}
/* ============ FIELDS ============ */
.field {
margin-bottom: 22px;
}
.field-row {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 8px;
}
.field label,
.term-field legend {
font-size: 13px;
font-weight: 600;
color: var(--ink-2);
padding: 0;
}
.field-val {
font-family: "Cormorant Garamond", serif;
font-size: 20px;
font-weight: 600;
color: var(--green-700);
}
.money-input {
display: flex;
align-items: center;
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
background: var(--white);
overflow: hidden;
margin-bottom: 12px;
transition: border-color 0.18s ease, box-shadow 0.18s ease;
}
.money-input:focus-within {
border-color: var(--brass);
box-shadow: 0 0 0 3px rgba(176, 141, 87, 0.18);
}
.money-input .prefix,
.money-input .suffix {
font-size: 13px;
font-weight: 600;
color: var(--muted);
padding: 0 12px;
background: var(--ivory);
align-self: stretch;
display: flex;
align-items: center;
}
.money-input .suffix {
border-left: 1px solid var(--line);
}
.money-input .prefix {
border-right: 1px solid var(--line);
}
.money-input input {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
font-family: inherit;
font-size: 16px;
font-weight: 600;
color: var(--ink);
padding: 11px 12px;
}
.field-split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
}
.hint {
margin: 0;
font-size: 12px;
color: var(--muted);
}
/* ============ RANGE ============ */
input[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
border-radius: 999px;
background: linear-gradient(90deg, var(--brass) 0%, var(--brass) var(--fill, 50%), var(--green-50) var(--fill, 50%), var(--green-50) 100%);
outline: none;
margin: 4px 0 0;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--white);
border: 2px solid var(--brass-d);
box-shadow: var(--shadow-sm);
cursor: grab;
transition: transform 0.12s ease;
}
input[type="range"]::-webkit-slider-thumb:active {
cursor: grabbing;
transform: scale(1.12);
}
input[type="range"]::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--white);
border: 2px solid var(--brass-d);
box-shadow: var(--shadow-sm);
cursor: grab;
}
input[type="range"]:focus-visible {
box-shadow: 0 0 0 3px rgba(176, 141, 87, 0.28);
}
/* ============ SEGMENTED ============ */
.term-field {
border: none;
margin: 0 0 18px;
padding: 0;
}
.term-field legend {
margin-bottom: 8px;
}
.seg {
display: inline-flex;
background: var(--ivory);
border: 1px solid var(--line);
border-radius: 999px;
padding: 3px;
}
.seg-opt {
position: relative;
cursor: pointer;
}
.seg-opt input {
position: absolute;
opacity: 0;
inset: 0;
cursor: pointer;
}
.seg-opt span {
display: block;
padding: 7px 20px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
color: var(--muted);
transition: all 0.18s ease;
}
.seg-opt input:checked + span {
background: var(--green-d);
color: var(--brass-50);
box-shadow: var(--shadow-sm);
}
.seg-opt input:focus-visible + span {
outline: 2px solid var(--brass);
outline-offset: 2px;
}
.assump {
margin: 4px 0 0;
font-size: 12.5px;
color: var(--muted);
padding: 12px 14px;
background: var(--green-50);
border-radius: var(--r-sm);
border-left: 3px solid var(--green-700);
}
.assump strong {
color: var(--green-d);
}
/* ============ RESULTS ============ */
.results {
background: linear-gradient(170deg, var(--paper), #fbf8f0);
}
.status-chip {
font-size: 12px;
font-weight: 600;
padding: 5px 13px;
border-radius: 999px;
letter-spacing: 0.3px;
}
.status-chip[data-tone="ok"] {
color: #1c6647;
background: rgba(47, 158, 111, 0.13);
border: 1px solid rgba(47, 158, 111, 0.3);
}
.status-chip[data-tone="warn"] {
color: var(--brass-d);
background: rgba(201, 138, 43, 0.13);
border: 1px solid rgba(201, 138, 43, 0.32);
}
.status-chip[data-tone="danger"] {
color: #9c3a2c;
background: rgba(196, 80, 62, 0.12);
border: 1px solid rgba(196, 80, 62, 0.3);
}
/* ============ GAUGE ============ */
.gauge-wrap {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 0 18px;
border-bottom: 1px solid var(--line);
margin-bottom: 20px;
}
.gauge {
width: 230px;
max-width: 100%;
height: auto;
}
.gauge-track {
fill: none;
stroke: var(--green-50);
stroke-width: 14;
stroke-linecap: round;
}
.gauge-fill {
fill: none;
stroke: url(#gaugeGrad);
stroke-width: 14;
stroke-linecap: round;
stroke-dasharray: 283;
stroke-dashoffset: 283;
transition: stroke-dashoffset 0.7s cubic-bezier(0.22, 1, 0.36, 1);
}
.gauge-needle {
stroke: var(--ink);
stroke-width: 3;
stroke-linecap: round;
transform-origin: 110px 120px;
transform: rotate(-90deg);
transition: transform 0.7s cubic-bezier(0.22, 1, 0.36, 1);
}
.gauge-hub {
fill: var(--green-d);
stroke: var(--brass);
stroke-width: 2;
}
.gauge-readout {
text-align: center;
margin-top: -6px;
}
.readout-label {
margin: 0;
font-size: 11px;
letter-spacing: 1.6px;
text-transform: uppercase;
color: var(--muted);
}
.readout-price {
margin: 4px 0 2px;
font-family: "Cormorant Garamond", serif;
font-size: 44px;
font-weight: 700;
line-height: 1;
color: var(--green-d);
}
.readout-range {
margin: 0;
font-size: 13px;
font-weight: 500;
color: var(--brass-d);
}
/* ============ METRICS ============ */
.metric-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
background: var(--line);
border: 1px solid var(--line);
border-radius: var(--r-md);
overflow: hidden;
margin-bottom: 22px;
}
.metric {
background: var(--paper);
padding: 14px 16px;
}
.metric-k {
margin: 0;
font-size: 11px;
letter-spacing: 0.6px;
text-transform: uppercase;
color: var(--muted);
}
.metric-v {
margin: 5px 0 1px;
font-family: "Cormorant Garamond", serif;
font-size: 26px;
font-weight: 600;
color: var(--ink);
}
.metric-sub {
margin: 0;
font-size: 12px;
color: var(--muted);
}
/* ============ BREAKDOWN ============ */
.breakdown {
margin-bottom: 22px;
}
.breakdown-title {
margin: 0 0 12px;
font-size: 13px;
font-weight: 600;
color: var(--ink-2);
}
.bars {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.bars li {
display: grid;
grid-template-columns: 130px 1fr 64px;
align-items: center;
gap: 12px;
}
.bar-k {
font-size: 12.5px;
color: var(--ink-2);
}
.bar-track {
height: 9px;
border-radius: 999px;
background: var(--green-50);
overflow: hidden;
}
.bar-fill {
display: block;
height: 100%;
border-radius: 999px;
background: var(--green-700);
transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1);
}
.bar-fill.alt {
background: var(--brass);
}
.bar-fill.alt2 {
background: var(--brass-d);
}
.bar-v {
text-align: right;
font-size: 13px;
font-weight: 600;
color: var(--ink);
font-variant-numeric: tabular-nums;
}
/* ============ CTA ============ */
.cta {
width: 100%;
font-family: inherit;
font-size: 15px;
font-weight: 600;
color: var(--brass-50);
background: linear-gradient(150deg, var(--green-700), var(--green-d));
border: 1px solid var(--green-d);
padding: 14px;
border-radius: var(--r-md);
cursor: pointer;
box-shadow: var(--shadow-md);
transition: transform 0.16s ease, box-shadow 0.16s ease;
}
.cta:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.cta:active {
transform: translateY(0);
}
.cta:focus-visible {
outline: 2px solid var(--brass);
outline-offset: 3px;
}
/* ============ LISTINGS ============ */
.listings {
margin-top: 48px;
}
.listings-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
padding-bottom: 16px;
margin-bottom: 22px;
border-bottom: 1px solid var(--line);
}
.section-h {
font-size: 30px;
color: var(--green-d);
}
.section-sub {
margin: 0;
font-size: 14px;
color: var(--muted);
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 22px;
}
.card {
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r-lg);
overflow: hidden;
box-shadow: var(--shadow-sm);
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
display: flex;
flex-direction: column;
}
.card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
border-color: var(--line-2);
}
.card.out-of-range {
opacity: 0.62;
}
.card.out-of-range:hover {
opacity: 0.82;
}
.photo {
position: relative;
aspect-ratio: 4 / 3;
width: 100%;
}
.photo-badges {
position: absolute;
top: 12px;
left: 12px;
right: 12px;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
}
.badge {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.3px;
padding: 5px 11px;
border-radius: 999px;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
}
.badge.fit {
color: #fff;
background: rgba(31, 61, 52, 0.82);
border: 1px solid rgba(176, 141, 87, 0.5);
}
.badge.over {
color: #fff;
background: rgba(196, 80, 62, 0.85);
}
.badge.price {
color: var(--green-d);
background: rgba(255, 253, 248, 0.92);
font-family: "Cormorant Garamond", serif;
font-size: 15px;
font-weight: 700;
}
.card-body {
padding: 16px 18px 18px;
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.card-title {
font-size: 21px;
color: var(--green-d);
line-height: 1.1;
}
.card-addr {
margin: 0;
font-size: 13px;
color: var(--muted);
}
.card-meta {
display: flex;
flex-wrap: wrap;
gap: 7px;
margin-top: 2px;
}
.chip {
font-size: 11.5px;
font-weight: 500;
color: var(--ink-2);
background: var(--ivory);
border: 1px solid var(--line);
padding: 4px 10px;
border-radius: 999px;
}
.card-foot {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: auto;
padding-top: 12px;
border-top: 1px solid var(--line);
}
.agent {
font-size: 12px;
color: var(--muted);
}
.agent strong {
color: var(--ink-2);
font-weight: 600;
}
.pay-note {
font-size: 12px;
font-weight: 600;
color: var(--brass-d);
font-variant-numeric: tabular-nums;
}
/* ============ FOOTER ============ */
.footer {
margin-top: 44px;
padding-top: 22px;
border-top: 1px solid var(--line);
text-align: center;
}
.footer p {
margin: 0;
font-size: 12.5px;
color: var(--muted);
}
/* ============ TOAST ============ */
.toast {
position: fixed;
left: 50%;
bottom: 28px;
transform: translate(-50%, 20px);
background: var(--green-d);
color: var(--brass-50);
font-size: 14px;
font-weight: 500;
padding: 12px 22px;
border-radius: 999px;
box-shadow: var(--shadow-lg);
border: 1px solid rgba(176, 141, 87, 0.4);
opacity: 0;
pointer-events: none;
transition: opacity 0.28s ease, transform 0.28s ease;
z-index: 50;
max-width: 90vw;
}
.toast.show {
opacity: 1;
transform: translate(-50%, 0);
}
/* ============ RESPONSIVE ============ */
@media (max-width: 920px) {
.grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 520px) {
.page {
padding: 20px 16px 36px;
}
.masthead {
flex-direction: column;
align-items: flex-start;
gap: 14px;
}
.masthead-nav {
flex-wrap: wrap;
gap: 12px;
}
.nav-pill {
order: 3;
}
.intro {
margin: 28px 0 22px;
}
.panel {
padding: 20px 18px;
}
.field-split {
grid-template-columns: 1fr;
gap: 18px;
}
.metric-grid {
grid-template-columns: 1fr 1fr;
}
.readout-price {
font-size: 38px;
}
.bars li {
grid-template-columns: 1fr;
gap: 5px;
}
.bar-v {
text-align: left;
}
.listings-head {
flex-direction: column;
gap: 6px;
}
.cards {
grid-template-columns: 1fr;
}
}(function () {
"use strict";
/* ---------- helpers ---------- */
var fmt0 = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
});
function money(n) {
if (!isFinite(n) || n < 0) n = 0;
return fmt0.format(Math.round(n));
}
function compact(n) {
if (n >= 1000000) return "$" + (n / 1000000).toFixed(n % 1000000 === 0 ? 0 : 1) + "M";
if (n >= 1000) return "$" + Math.round(n / 1000) + "k";
return money(n);
}
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
function num(id) {
var el = document.getElementById(id);
var v = parseFloat(el.value);
return isNaN(v) ? 0 : v;
}
var toastEl = document.getElementById("toast");
var toastTimer = null;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("show");
}, 2600);
}
/* ---------- range fill paint ---------- */
function paintRange(el) {
var min = parseFloat(el.min),
max = parseFloat(el.max),
val = parseFloat(el.value);
var pct = ((val - min) / (max - min)) * 100;
el.style.setProperty("--fill", pct + "%");
}
/* ---------- sample listings ---------- */
var LISTINGS = [
{
title: "The Aldergrove",
addr: "42 Harlowe Terrace · Westhaven",
price: 489000,
beds: 3,
baths: 2,
sqft: 1980,
agent: "Priya Ackerman",
grad: "linear-gradient(135deg,#c9b79a 0%,#a8895f 45%,#6d5436 100%)",
overlay: "radial-gradient(120% 90% at 20% 0%,rgba(255,245,225,.55),transparent 55%)",
},
{
title: "Linnea Row House",
addr: "7 Cobble Mews · Briarpoint",
price: 575000,
beds: 4,
baths: 3,
sqft: 2340,
agent: "Marcus Vela",
grad: "linear-gradient(135deg,#2f4a40 0%,#3c6053 50%,#243a32 100%)",
overlay: "radial-gradient(130% 100% at 80% 10%,rgba(176,141,87,.4),transparent 60%)",
},
{
title: "Calder Courtyard Flat",
addr: "300 Vinewood Ave · Westhaven",
price: 612000,
beds: 2,
baths: 2,
sqft: 1640,
agent: "Dora Whitfield",
grad: "linear-gradient(135deg,#b7704f 0%,#8a4f37 50%,#5c3322 100%)",
overlay: "radial-gradient(120% 90% at 30% 100%,rgba(255,230,200,.5),transparent 55%)",
},
{
title: "Marlowe Hill Estate",
addr: "18 Crestline Loop · Highmoor",
price: 845000,
beds: 5,
baths: 4,
sqft: 3420,
agent: "Theo Brandt",
grad: "linear-gradient(135deg,#3a3f52 0%,#566079 50%,#2a2f3e 100%)",
overlay: "radial-gradient(130% 100% at 70% 0%,rgba(200,210,235,.4),transparent 60%)",
},
];
/* ---------- core affordability math ---------- */
function monthlyPI(principal, annualRate, years) {
var r = annualRate / 100 / 12;
var n = years * 12;
if (r === 0) return principal / n;
return (principal * r) / (1 - Math.pow(1 + r, -n));
}
// Given a target monthly P&I, invert to find affordable loan principal.
function loanFromPI(pi, annualRate, years) {
var r = annualRate / 100 / 12;
var n = years * 12;
if (r === 0) return pi * n;
return (pi * (1 - Math.pow(1 + r, -n))) / r;
}
var TAX_RATE = 0.0115; // annual property tax as % of price
var INS_HOA_MONTHLY_PER_1000 = 0.55; // insurance + HOA per $1000 of price / month
function compute() {
var income = num("income");
var debts = num("debts");
var down = num("down");
var rate = num("rate");
var dti = num("dti");
var term = parseInt(
document.querySelector('input[name="term"]:checked').value,
10
);
// Max monthly housing the lender allows under DTI.
var maxTotalDebt = (income / 12) * (dti / 100);
var maxHousing = Math.max(0, maxTotalDebt - debts);
// Solve price iteratively: housing = P&I(price-down) + tax + ins/hoa.
// tax & ins scale with price, so we converge.
var price = (down + loanFromPI(maxHousing, rate, term)); // first guess ignoring escrow
for (var i = 0; i < 24; i++) {
var taxM = (price * TAX_RATE) / 12;
var insM = (price / 1000) * INS_HOA_MONTHLY_PER_1000;
var availPI = Math.max(0, maxHousing - taxM - insM);
var loan = loanFromPI(availPI, rate, term);
var newPrice = loan + down;
if (Math.abs(newPrice - price) < 50) {
price = newPrice;
break;
}
price = newPrice;
}
price = Math.max(down, price);
var loan = Math.max(0, price - down);
var pi = monthlyPI(loan, rate, term);
var taxM = (price * TAX_RATE) / 12;
var insM = (price / 1000) * INS_HOA_MONTHLY_PER_1000;
var housing = pi + taxM + insM;
var downPct = price > 0 ? (down / price) * 100 : 0;
// "Comfort" assessment vs. how aggressive the DTI target is.
var tone, label;
if (dti <= 33 && downPct >= 18) {
tone = "ok";
label = "Comfortable";
} else if (dti <= 40) {
tone = "warn";
label = "Stretch";
} else {
tone = "danger";
label = "Aggressive";
}
if (housing <= 0) {
tone = "danger";
label = "Debts too high";
}
return {
income: income,
down: down,
dti: dti,
price: price,
loan: loan,
pi: pi,
taxM: taxM,
insM: insM,
housing: housing,
downPct: downPct,
tone: tone,
label: label,
};
}
/* ---------- gauge ---------- */
var GAUGE_LEN = 283; // length of the semicircle arc path
var GAUGE_MIN = 150000;
var GAUGE_MAX = 1500000;
function setGauge(price) {
var t = clamp((price - GAUGE_MIN) / (GAUGE_MAX - GAUGE_MIN), 0, 1);
var fill = document.getElementById("gauge-fill");
fill.style.strokeDashoffset = String(GAUGE_LEN - GAUGE_LEN * t);
var needle = document.getElementById("gauge-needle");
var deg = -90 + t * 180;
needle.style.transform = "rotate(" + deg + "deg)";
}
/* ---------- render results ---------- */
function set(id, txt) {
document.getElementById(id).textContent = txt;
}
var lastMaxPrice = 0;
function render() {
var r = compute();
lastMaxPrice = r.price;
// headline
set("max-price", money(r.price));
var low = r.price * 0.85;
set("price-range", "Sweet spot " + compact(low) + " – " + compact(r.price));
// gauge
setGauge(r.price);
// status chip
var chip = document.getElementById("status-chip");
chip.textContent = r.label;
chip.setAttribute("data-tone", r.tone);
// metrics
set("m-payment", money(r.housing));
set("m-payment-sub", "at " + r.dti + "% DTI");
set("m-loan", money(r.loan));
set("m-income", money(r.income));
set("m-down", Math.round(r.downPct) + "%");
set("m-down-sub", money(r.down) + " cash");
// breakdown
var total = r.housing > 0 ? r.housing : 1;
document.getElementById("bar-pi").style.width =
(r.pi / total) * 100 + "%";
document.getElementById("bar-tax").style.width =
(r.taxM / total) * 100 + "%";
document.getElementById("bar-ins").style.width =
(r.insM / total) * 100 + "%";
set("val-pi", money(r.pi));
set("val-tax", money(r.taxM));
set("val-ins", money(r.insM));
renderCards(r.price);
}
/* ---------- listing cards ---------- */
var cardsEl = document.getElementById("cards");
function payForPrice(price) {
// quick payment preview using current rate/term and current down (capped).
var rate = num("rate");
var term = parseInt(
document.querySelector('input[name="term"]:checked').value,
10
);
var down = Math.min(num("down"), price * 0.4);
var loan = Math.max(0, price - down);
var pi = monthlyPI(loan, rate, term);
var taxM = (price * TAX_RATE) / 12;
var insM = (price / 1000) * INS_HOA_MONTHLY_PER_1000;
return pi + taxM + insM;
}
function renderCards(maxPrice) {
cardsEl.innerHTML = "";
var fits = 0;
LISTINGS.forEach(function (l) {
var inRange = l.price <= maxPrice + 1;
if (inRange) fits++;
var card = document.createElement("article");
card.className = "card" + (inRange ? "" : " out-of-range");
var pay = payForPrice(l.price);
var badgeHtml = inRange
? '<span class="badge fit">In your range</span>'
: '<span class="badge over">Over by ' +
compact(l.price - maxPrice) +
"</span>";
card.innerHTML =
'<div class="photo" style="background:' +
l.grad +
";background-image:" +
l.overlay +
"," +
l.grad +
'">' +
'<div class="photo-badges">' +
badgeHtml +
'<span class="badge price">' +
compact(l.price) +
"</span>" +
"</div></div>" +
'<div class="card-body">' +
'<h3 class="card-title">' +
l.title +
"</h3>" +
'<p class="card-addr">' +
l.addr +
"</p>" +
'<div class="card-meta">' +
'<span class="chip">' +
l.beds +
" bd</span>" +
'<span class="chip">' +
l.baths +
" ba</span>" +
'<span class="chip">' +
l.sqft.toLocaleString() +
" sqft</span>" +
"</div>" +
'<div class="card-foot">' +
'<span class="agent">Listed by <strong>' +
l.agent +
"</strong></span>" +
'<span class="pay-note">~' +
money(pay) +
"/mo</span>" +
"</div>" +
"</div>";
cardsEl.appendChild(card);
});
var summary = document.getElementById("match-summary");
summary.textContent =
fits +
" of " +
LISTINGS.length +
" listings fit your recommended price.";
}
/* ---------- bind number <-> range pairs + outputs ---------- */
function bindPair(numId, rangeId, outId, formatter) {
var n = document.getElementById(numId);
var rng = document.getElementById(rangeId);
function syncFromNum() {
var v = clamp(parseFloat(n.value) || 0, parseFloat(rng.min), parseFloat(rng.max));
rng.value = String(v);
paintRange(rng);
document.getElementById(outId).textContent = formatter(parseFloat(n.value) || 0);
render();
}
function syncFromRange() {
n.value = rng.value;
paintRange(rng);
document.getElementById(outId).textContent = formatter(parseFloat(rng.value));
render();
}
n.addEventListener("input", syncFromNum);
rng.addEventListener("input", syncFromRange);
paintRange(rng);
}
bindPair("income", "income-range", "income-out", money);
bindPair("debts", "debts-range", "debts-out", money);
bindPair("down", "down-range", "down-out", money);
// standalone sliders (rate, dti)
var rateEl = document.getElementById("rate");
rateEl.addEventListener("input", function () {
paintRange(rateEl);
set("rate-out", parseFloat(rateEl.value).toFixed(2) + "%");
render();
});
paintRange(rateEl);
var dtiEl = document.getElementById("dti");
dtiEl.addEventListener("input", function () {
paintRange(dtiEl);
set("dti-out", Math.round(parseFloat(dtiEl.value)) + "%");
render();
});
paintRange(dtiEl);
// term radios
Array.prototype.forEach.call(
document.querySelectorAll('input[name="term"]'),
function (el) {
el.addEventListener("change", render);
}
);
/* ---------- reset ---------- */
var DEFAULTS = {
income: 140000,
debts: 650,
down: 90000,
rate: 6.5,
dti: 36,
term: "30",
};
document.getElementById("reset-btn").addEventListener("click", function () {
document.getElementById("income").value = DEFAULTS.income;
document.getElementById("income-range").value = DEFAULTS.income;
document.getElementById("debts").value = DEFAULTS.debts;
document.getElementById("debts-range").value = DEFAULTS.debts;
document.getElementById("down").value = DEFAULTS.down;
document.getElementById("down-range").value = DEFAULTS.down;
document.getElementById("rate").value = DEFAULTS.rate;
document.getElementById("dti").value = DEFAULTS.dti;
document.querySelector(
'input[name="term"][value="' + DEFAULTS.term + '"]'
).checked = true;
set("income-out", money(DEFAULTS.income));
set("debts-out", money(DEFAULTS.debts));
set("down-out", money(DEFAULTS.down));
set("rate-out", DEFAULTS.rate.toFixed(2) + "%");
set("dti-out", DEFAULTS.dti + "%");
["income-range", "debts-range", "down-range", "rate", "dti"].forEach(
function (id) {
paintRange(document.getElementById(id));
}
);
render();
toast("Reset to sample buyer profile");
});
/* ---------- save ---------- */
document.getElementById("save-btn").addEventListener("click", function () {
toast("Estimate saved — " + money(lastMaxPrice) + " max. Scroll to your matches.");
document.getElementById("listings").scrollIntoView({ behavior: "smooth", block: "start" });
});
/* ---------- init ---------- */
render();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Affordability Estimator — Meridian & Hale</title>
<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=Cormorant+Garamond:wght@500;600;700&family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="masthead">
<div class="brandmark">
<span class="brand-glyph" aria-hidden="true">M&H</span>
<div class="brand-text">
<p class="brand-name">Meridian & Hale</p>
<p class="brand-tag">Private Residential Advisory</p>
</div>
</div>
<nav class="masthead-nav" aria-label="Sections">
<a href="#estimator" class="nav-link is-active">Affordability</a>
<a href="#listings" class="nav-link">Matched Homes</a>
<span class="nav-pill">Pre-qualification preview</span>
</nav>
</header>
<main class="layout" id="estimator">
<section class="intro">
<p class="eyebrow">Buyer Tools · 2026</p>
<h1 class="display">Find the home price you can comfortably afford.</h1>
<p class="lede">
Enter your income, obligations, and savings. We model a lender-style
debt-to-income calculation to recommend a realistic purchase range —
then preview homes that fit it.
</p>
</section>
<div class="grid">
<!-- ============ INPUTS ============ -->
<form class="panel inputs" id="afford-form" novalidate>
<div class="panel-head">
<h2 class="panel-title">Your finances</h2>
<button type="button" class="ghost-btn" id="reset-btn">Reset</button>
</div>
<div class="field">
<div class="field-row">
<label for="income">Gross annual income</label>
<output class="field-val" id="income-out">$140,000</output>
</div>
<div class="money-input">
<span class="prefix">$</span>
<input
type="number"
id="income"
inputmode="numeric"
min="0"
step="1000"
value="140000"
aria-describedby="income-hint"
/>
<span class="suffix">/ yr</span>
</div>
<input type="range" id="income-range" min="30000" max="500000" step="1000" value="140000" aria-label="Annual income slider" />
<p class="hint" id="income-hint">Pre-tax household income from all earners.</p>
</div>
<div class="field">
<div class="field-row">
<label for="debts">Monthly debt payments</label>
<output class="field-val" id="debts-out">$650</output>
</div>
<div class="money-input">
<span class="prefix">$</span>
<input
type="number"
id="debts"
inputmode="numeric"
min="0"
step="25"
value="650"
aria-describedby="debts-hint"
/>
<span class="suffix">/ mo</span>
</div>
<input type="range" id="debts-range" min="0" max="6000" step="25" value="650" aria-label="Monthly debts slider" />
<p class="hint" id="debts-hint">Car loans, student loans, credit cards, etc.</p>
</div>
<div class="field">
<div class="field-row">
<label for="down">Down payment</label>
<output class="field-val" id="down-out">$90,000</output>
</div>
<div class="money-input">
<span class="prefix">$</span>
<input
type="number"
id="down"
inputmode="numeric"
min="0"
step="1000"
value="90000"
aria-describedby="down-hint"
/>
<span class="suffix">cash</span>
</div>
<input type="range" id="down-range" min="0" max="400000" step="1000" value="90000" aria-label="Down payment slider" />
<p class="hint" id="down-hint">Cash applied toward purchase, lowering the loan.</p>
</div>
<div class="field-split">
<div class="field">
<div class="field-row">
<label for="rate">Interest rate</label>
<output class="field-val" id="rate-out">6.50%</output>
</div>
<input type="range" id="rate" min="2" max="10" step="0.05" value="6.5" aria-label="Interest rate slider" />
</div>
<div class="field">
<div class="field-row">
<label for="dti">DTI target</label>
<output class="field-val" id="dti-out">36%</output>
</div>
<input type="range" id="dti" min="28" max="45" step="1" value="36" aria-label="Debt to income target slider" />
</div>
</div>
<fieldset class="field term-field">
<legend>Loan term</legend>
<div class="seg" role="radiogroup" aria-label="Loan term">
<label class="seg-opt">
<input type="radio" name="term" value="15" />
<span>15 yr</span>
</label>
<label class="seg-opt">
<input type="radio" name="term" value="30" checked />
<span>30 yr</span>
</label>
</div>
</fieldset>
<p class="assump">
Estimate folds in <strong>~1.15%</strong> property tax, insurance &
HOA into the monthly housing figure.
</p>
</form>
<!-- ============ RESULTS ============ -->
<section class="panel results" aria-live="polite">
<div class="panel-head">
<h2 class="panel-title">What you can afford</h2>
<span class="status-chip" id="status-chip" data-tone="ok">Comfortable</span>
</div>
<div class="gauge-wrap">
<svg class="gauge" viewBox="0 0 220 130" role="img" aria-labelledby="gauge-label">
<title id="gauge-label">Affordability gauge</title>
<defs>
<linearGradient id="gaugeGrad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#2f9e6f" />
<stop offset="0.6" stop-color="#b08d57" />
<stop offset="1" stop-color="#c4503e" />
</linearGradient>
</defs>
<path class="gauge-track" d="M20 120 A90 90 0 0 1 200 120" />
<path class="gauge-fill" id="gauge-fill" d="M20 120 A90 90 0 0 1 200 120" />
<line class="gauge-needle" id="gauge-needle" x1="110" y1="120" x2="110" y2="42" />
<circle class="gauge-hub" cx="110" cy="120" r="7" />
</svg>
<div class="gauge-readout">
<p class="readout-label">Recommended max price</p>
<p class="readout-price" id="max-price">$612,000</p>
<p class="readout-range" id="price-range">Sweet spot $520k – $612k</p>
</div>
</div>
<div class="metric-grid">
<div class="metric">
<p class="metric-k">Est. monthly housing</p>
<p class="metric-v" id="m-payment">$3,540</p>
<p class="metric-sub" id="m-payment-sub">at 36% DTI</p>
</div>
<div class="metric">
<p class="metric-k">Loan amount</p>
<p class="metric-v" id="m-loan">$522,000</p>
<p class="metric-sub">after down payment</p>
</div>
<div class="metric">
<p class="metric-k">Income needed</p>
<p class="metric-v" id="m-income">$140,000</p>
<p class="metric-sub">to clear this band</p>
</div>
<div class="metric">
<p class="metric-k">Down payment</p>
<p class="metric-v" id="m-down">15%</p>
<p class="metric-sub" id="m-down-sub">$90,000 cash</p>
</div>
</div>
<div class="breakdown">
<p class="breakdown-title">Monthly housing breakdown</p>
<ul class="bars" id="breakdown-bars">
<li>
<span class="bar-k">Principal & interest</span>
<div class="bar-track"><i class="bar-fill" id="bar-pi" style="width:74%"></i></div>
<span class="bar-v" id="val-pi">$2,620</span>
</li>
<li>
<span class="bar-k">Property tax</span>
<div class="bar-track"><i class="bar-fill alt" id="bar-tax" style="width:16%"></i></div>
<span class="bar-v" id="val-tax">$587</span>
</li>
<li>
<span class="bar-k">Insurance & HOA</span>
<div class="bar-track"><i class="bar-fill alt2" id="bar-ins" style="width:10%"></i></div>
<span class="bar-v" id="val-ins">$333</span>
</li>
</ul>
</div>
<button type="button" class="cta" id="save-btn">Save estimate & see matches</button>
</section>
</div>
<!-- ============ MATCHED LISTINGS ============ -->
<section class="listings" id="listings" aria-labelledby="listings-h">
<div class="listings-head">
<h2 id="listings-h" class="section-h">Homes in your range</h2>
<p class="section-sub" id="match-summary">3 of 4 listings fit your recommended price.</p>
</div>
<div class="cards" id="cards"></div>
</section>
</main>
<footer class="footer">
<p>Meridian & Hale · 118 Carrow Lane, Westhaven · Estimates are illustrative, not a loan offer.</p>
</footer>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Affordability Estimator
A premium, editorial buyer tool that answers the first question every shopper asks: what can I actually afford? The left panel collects gross annual income, monthly debt obligations, available down-payment cash, interest rate, debt-to-income target and loan term. Number fields and matching sliders stay in sync, each with a live formatted readout and a brass-filled track.
The right panel reacts instantly. A semicircular SVG gauge sweeps a green-to-brass-to-red arc with a needle that points to the recommended maximum price, computed by inverting a lender-style DTI formula and converging on a price that folds in property tax, insurance and HOA escrow. Below it, four metric tiles surface the estimated monthly housing payment, loan amount, income needed and down-payment percentage, plus a horizontal breakdown of principal & interest versus taxes and insurance. A status chip reads Comfortable, Stretch or Aggressive depending on how hard the inputs push the DTI and down-payment ratios.
Saving the estimate scrolls to a grid of fictional listings rendered with warm architectural CSS gradients standing in for property photography. Each card carries price, beds, baths and square footage badges, a flag for whether it fits the recommended band (or how far over it is), and a quick monthly-payment preview at the current rate and term. A small toast helper confirms saves and resets.
Illustrative UI only — sample listings and data are fictional; not a real real-estate service.