Mortgage Calculator & Visualizer
Overview
A common question for any amortizing loan: how much faster does it get paid off if I add an extra payment each month, and when do principal payments start exceeding interest? Early in a loan almost every dollar goes to interest this tool shows when that flips.
It answers both questions side by side for any combination of principal, APR, and monthly payment. The chart makes the crossover point the month principal payments first exceed interest visible at a glance, alongside the projected payoff date.
How to read the chart: monthly principal is shown in bluish-green, monthly interest in vermillion. The optional extra-payment scenario overlays as dashed lines in the same hues. Two vertical markers flag the crossover month a blue diamond for the current plan, a purple triangle for the extra-payment plan. Adjust the sliders below to update the calculation.
Try it
#| '!! shinylive warning !!': |
#| shinylive does not work in self-contained HTML documents.
#| Please set `embed-resources: false` in your metadata.
#| standalone: true
#| viewerHeight: 720
import io
import re
from datetime import datetime
import matplotlib
matplotlib.use("Agg")
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
from dateutil.relativedelta import relativedelta
from shiny import App, render, ui, reactive
def calculate_amortization(principal, apr, monthly_payment):
monthly_rate = apr / 100 / 12
balance = principal
month = 0
principal_payments, interest_payments, balances = [], [], []
while balance > 0:
interest = balance * monthly_rate
if monthly_payment <= interest:
return None
principal_paid = min(monthly_payment - interest, balance)
balance -= principal_paid
month += 1
principal_payments.append(principal_paid)
interest_payments.append(interest)
balances.append(balance)
crossover_month = next(
(i + 1 for i, (p, intr) in enumerate(zip(principal_payments, interest_payments)) if p > intr),
None,
)
start_date = datetime.now()
return {
"months": month,
"payoff_date": start_date + relativedelta(months=month),
"dates": [start_date + relativedelta(months=i) for i in range(month)],
"principal_payments": principal_payments,
"interest_payments": interest_payments,
"balances": balances,
"crossover_date": start_date + relativedelta(months=crossover_month) if crossover_month else None,
"total_interest": sum(interest_payments),
}
app_ui = ui.page_fluid(
ui.layout_columns(
ui.input_slider("principal", "Principal balance ($)",
min=50_000, max=2_000_000, value=1_000_000, step=5_000),
ui.input_slider("apr", "APR (%)",
min=1.0, max=12.0, value=5.875, step=0.125),
ui.input_slider("monthly_payment", "Monthly payment ($)",
min=500, max=15_000, value=7_054, step=50),
ui.input_slider("extra_payment", "Extra monthly ($)",
min=0, max=5_000, value=1_000, step=50),
col_widths=(3, 3, 3, 3),
),
ui.output_ui("plot"),
ui.output_ui("summary"),
)
def server(input, output, session):
@reactive.calc
def schedules():
current = calculate_amortization(input.principal(), input.apr(), input.monthly_payment())
extra = None
if input.extra_payment() > 0:
extra = calculate_amortization(
input.principal(), input.apr(),
input.monthly_payment() + input.extra_payment(),
)
return current, extra
@render.ui
def plot():
current, extra = schedules()
if current is None:
return ui.div(
ui.markdown(
"**This loan never gets paid off.** At these settings the monthly "
"payment is smaller than the first month's interest, so the balance "
"would only keep growing. Try raising the monthly payment, or "
"lowering the APR or principal."
),
style=(
"display:flex; align-items:center; justify-content:center; "
"min-height:320px; padding:2rem 1.5rem; text-align:center; "
"font-size:1.05rem; line-height:1.5; "
"border:1px dashed #D55E00; border-radius:8px; "
"background:#fff7f3; color:#7E2C16;"
),
)
fig, ax = plt.subplots(figsize=(16, 8))
# Okabe-Ito colorblind-safe palette
PRINCIPAL = "#009E73" # bluish green
INTEREST = "#D55E00" # vermillion
XOVER_CURRENT = "#0072B2" # blue
XOVER_EXTRA = "#CC79A7" # reddish purple
# Trim the final partial-month payment from each series so the lines
# and fills end cleanly at the last "normal" month instead of
# dropping to a sliver in the payoff month.
ax.fill_between(current["dates"][:-1], current["principal_payments"][:-1],
alpha=0.55, label="Principal (current)", color=PRINCIPAL)
ax.fill_between(current["dates"][:-1], current["interest_payments"][:-1],
alpha=0.55, label="Interest (current)", color=INTEREST)
if extra:
ax.plot(extra["dates"][:-1], extra["principal_payments"][:-1],
linestyle="--", linewidth=2.5, color=PRINCIPAL, label="Principal (extra)")
ax.plot(extra["dates"][:-1], extra["interest_payments"][:-1],
linestyle="--", linewidth=2.5, color=INTEREST, label="Interest (extra)")
# Capture y-limits before adding vertical crossover lines so the
# markers can be anchored to the visible chart top/bottom.
ymin, ymax = ax.get_ylim()
if extra and extra.get("crossover_date"):
ax.plot([extra["crossover_date"], extra["crossover_date"]], [ymin, ymax],
color=XOVER_EXTRA, linestyle=":", linewidth=2.5,
marker="^", markersize=12, markevery=[0, 1],
markerfacecolor=XOVER_EXTRA, markeredgecolor="white", markeredgewidth=1.2,
label=f"Crossover w/ extra: {extra['crossover_date'].strftime('%b %Y')}",
zorder=10, clip_on=False)
if current["crossover_date"]:
ax.plot([current["crossover_date"], current["crossover_date"]], [ymin, ymax],
color=XOVER_CURRENT, linestyle="--", linewidth=2.5,
marker="D", markersize=10, markevery=[0, 1],
markerfacecolor=XOVER_CURRENT, markeredgecolor="white", markeredgewidth=1.2,
label=f"Crossover: {current['crossover_date'].strftime('%b %Y')}",
zorder=10, clip_on=False)
ax.set_ylim(ymin, ymax)
ax.set_ylabel("Payment ($)", fontsize=11)
ax.grid(True, alpha=0.3)
ax.xaxis.set_major_locator(mdates.YearLocator(2))
ax.xaxis.set_major_formatter(mdates.DateFormatter("%b %Y"))
ax.tick_params(axis="both", labelsize=10)
plt.setp(ax.xaxis.get_majorticklabels(), rotation=45, ha="right")
ax.legend(loc="lower center", bbox_to_anchor=(0.5, 1.04),
ncol=3, fontsize=11, frameon=False)
plt.tight_layout()
# Render as inline SVG so the chart scales to the container width and
# stays crisp at any zoom level (no PNG rasterization).
buf = io.StringIO()
fig.savefig(buf, format="svg", bbox_inches="tight")
plt.close(fig)
svg = buf.getvalue()
svg = svg[svg.find("<svg"):]
svg = re.sub(
r'<svg([^>]*?)\s+width="[^"]+"\s+height="[^"]+"',
r'<svg\1 style="width:100%;height:auto;display:block;"',
svg,
count=1,
)
return ui.HTML(svg)
@render.ui
def summary():
current, extra = schedules()
if current is None:
return ui.markdown("**Monthly payment must exceed the first month's interest.**")
def years_months(m):
return f"{m // 12} yrs, {m % 12} mos"
rows = [
ui.tags.tr(ui.tags.th(""), ui.tags.th("Current"), ui.tags.th("With extra")),
ui.tags.tr(
ui.tags.td("Payoff date"),
ui.tags.td(current["payoff_date"].strftime("%b %Y")),
ui.tags.td(extra["payoff_date"].strftime("%b %Y") if extra else "—"),
),
ui.tags.tr(
ui.tags.td("Total months"),
ui.tags.td(years_months(current["months"])),
ui.tags.td(years_months(extra["months"]) if extra else "—"),
),
ui.tags.tr(
ui.tags.td("Total interest"),
ui.tags.td(f"${current['total_interest']:,.0f}"),
ui.tags.td(f"${extra['total_interest']:,.0f}" if extra else "—"),
),
]
if extra:
months_saved = current["months"] - extra["months"]
interest_saved = current["total_interest"] - extra["total_interest"]
rows.append(ui.tags.tr(
ui.tags.td(ui.tags.b("Savings")),
ui.tags.td(""),
ui.tags.td(ui.tags.b(f"{years_months(months_saved)}, ${interest_saved:,.0f} interest")),
))
return ui.tags.table(*rows, style="margin-top:1em; width:100%; border-collapse:collapse;")
app = App(app_ui, server)
The first time this page loads, your browser downloads the Python runtime (~10 MB). After that, it stays cached and the tool runs instantly. All calculations happen locally in your browser nothing is sent to a server.
Approach
The math is a straight monthly amortization loop. For each month:
- Compute interest on the current balance:
interest = balance × (APR / 12). - Apply the remainder of the monthly payment to principal.
- Subtract from balance, append to the running schedule.
Two derived quantities make the visualization useful:
- Crossover month the first month when principal payment exceeds interest payment. Early in a loan, almost every dollar goes to interest; this date marks the inflection point.
- Comparison delta running a second amortization with the extra payment added quantifies the time and interest saved.
Accessibility
The chart uses the Okabe-Ito colorblind-safe palette (bluish-green and vermillion as the primary contrast pair, blue and reddish-purple for the crossover markers) so the visual story holds up under deuteranopia, protanopia, and tritanopia. Information is multi-encoded color, line style (solid vs dashed vs dotted), and shape (diamond vs triangle marker) so the chart remains readable for users who rely on shape or pattern rather than hue. A descriptive paragraph above the tool summarizes the chart for screen-reader users, and the summary table below provides the same numerical information in non-graphical form. These choices target Section 508 / WCAG 2.1 AA conformance.
Source
This is the exact Python that runs in your browser via Shinylive the amortization math, the Shiny UI (sliders, plot output, summary table), and the matplotlib chart code with the Okabe-Ito palette and shape-marked crossover lines.
app.py
import io
import re
from datetime import datetime
import matplotlib
matplotlib.use("Agg")
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
from dateutil.relativedelta import relativedelta
from shiny import App, render, ui, reactive
def calculate_amortization(principal, apr, monthly_payment):
monthly_rate = apr / 100 / 12
balance = principal
month = 0
principal_payments, interest_payments, balances = [], [], []
while balance > 0:
interest = balance * monthly_rate
if monthly_payment <= interest:
return None
principal_paid = min(monthly_payment - interest, balance)
balance -= principal_paid
month += 1
principal_payments.append(principal_paid)
interest_payments.append(interest)
balances.append(balance)
crossover_month = next(
(i + 1 for i, (p, intr) in enumerate(zip(principal_payments, interest_payments)) if p > intr),
None,
)
start_date = datetime.now()
return {
"months": month,
"payoff_date": start_date + relativedelta(months=month),
"dates": [start_date + relativedelta(months=i) for i in range(month)],
"principal_payments": principal_payments,
"interest_payments": interest_payments,
"balances": balances,
"crossover_date": start_date + relativedelta(months=crossover_month) if crossover_month else None,
"total_interest": sum(interest_payments),
}
app_ui = ui.page_fluid(
ui.layout_columns(
ui.input_slider("principal", "Principal balance ($)",
min=50_000, max=2_000_000, value=1_000_000, step=5_000),
ui.input_slider("apr", "APR (%)",
min=1.0, max=12.0, value=5.875, step=0.125),
ui.input_slider("monthly_payment", "Monthly payment ($)",
min=500, max=15_000, value=7_054, step=50),
ui.input_slider("extra_payment", "Extra monthly ($)",
min=0, max=5_000, value=1_000, step=50),
col_widths=(3, 3, 3, 3),
),
ui.output_ui("plot"),
ui.output_ui("summary"),
)
def server(input, output, session):
@reactive.calc
def schedules():
current = calculate_amortization(input.principal(), input.apr(), input.monthly_payment())
extra = None
if input.extra_payment() > 0:
extra = calculate_amortization(
input.principal(), input.apr(),
input.monthly_payment() + input.extra_payment(),
)
return current, extra
@render.ui
def plot():
current, extra = schedules()
if current is None:
return ui.div(
ui.markdown(
"**This loan never gets paid off.** At these settings the monthly "
"payment is smaller than the first month's interest, so the balance "
"would only keep growing. Try raising the monthly payment, or "
"lowering the APR or principal."
),
style=(
"display:flex; align-items:center; justify-content:center; "
"min-height:320px; padding:2rem 1.5rem; text-align:center; "
"font-size:1.05rem; line-height:1.5; "
"border:1px dashed #D55E00; border-radius:8px; "
"background:#fff7f3; color:#7E2C16;"
),
)
fig, ax = plt.subplots(figsize=(16, 8))
# Okabe-Ito colorblind-safe palette
PRINCIPAL = "#009E73" # bluish green
INTEREST = "#D55E00" # vermillion
XOVER_CURRENT = "#0072B2" # blue
XOVER_EXTRA = "#CC79A7" # reddish purple
# Trim the final partial-month payment so the lines and fills end
# cleanly at the last "normal" month instead of dropping to a sliver
# in the payoff month.
ax.fill_between(current["dates"][:-1], current["principal_payments"][:-1],
alpha=0.55, label="Principal (current)", color=PRINCIPAL)
ax.fill_between(current["dates"][:-1], current["interest_payments"][:-1],
alpha=0.55, label="Interest (current)", color=INTEREST)
if extra:
ax.plot(extra["dates"][:-1], extra["principal_payments"][:-1],
linestyle="--", linewidth=2.5, color=PRINCIPAL, label="Principal (extra)")
ax.plot(extra["dates"][:-1], extra["interest_payments"][:-1],
linestyle="--", linewidth=2.5, color=INTEREST, label="Interest (extra)")
# Capture y-limits before adding vertical crossover lines so the
# markers can be anchored to the visible chart top/bottom.
ymin, ymax = ax.get_ylim()
if extra and extra.get("crossover_date"):
ax.plot([extra["crossover_date"], extra["crossover_date"]], [ymin, ymax],
color=XOVER_EXTRA, linestyle=":", linewidth=2.5,
marker="^", markersize=12, markevery=[0, 1],
markerfacecolor=XOVER_EXTRA, markeredgecolor="white", markeredgewidth=1.2,
label=f"Crossover w/ extra: {extra['crossover_date'].strftime('%b %Y')}",
zorder=10, clip_on=False)
if current["crossover_date"]:
ax.plot([current["crossover_date"], current["crossover_date"]], [ymin, ymax],
color=XOVER_CURRENT, linestyle="--", linewidth=2.5,
marker="D", markersize=10, markevery=[0, 1],
markerfacecolor=XOVER_CURRENT, markeredgecolor="white", markeredgewidth=1.2,
label=f"Crossover: {current['crossover_date'].strftime('%b %Y')}",
zorder=10, clip_on=False)
ax.set_ylim(ymin, ymax)
ax.set_ylabel("Payment ($)", fontsize=11)
ax.grid(True, alpha=0.3)
ax.xaxis.set_major_locator(mdates.YearLocator(2))
ax.xaxis.set_major_formatter(mdates.DateFormatter("%b %Y"))
ax.tick_params(axis="both", labelsize=10)
plt.setp(ax.xaxis.get_majorticklabels(), rotation=45, ha="right")
ax.legend(loc="lower center", bbox_to_anchor=(0.5, 1.04),
ncol=3, fontsize=11, frameon=False)
plt.tight_layout()
# Render as inline SVG so the chart scales to the container width and
# stays crisp at any zoom level (no PNG rasterization).
buf = io.StringIO()
fig.savefig(buf, format="svg", bbox_inches="tight")
plt.close(fig)
svg = buf.getvalue()
svg = svg[svg.find("<svg"):]
svg = re.sub(
r'<svg([^>]*?)\s+width="[^"]+"\s+height="[^"]+"',
r'<svg\1 style="width:100%;height:auto;display:block;"',
svg,
count=1,
)
return ui.HTML(svg)
@render.ui
def summary():
current, extra = schedules()
if current is None:
return ui.markdown("**Monthly payment must exceed the first month's interest.**")
def years_months(m):
return f"{m // 12} yrs, {m % 12} mos"
rows = [
ui.tags.tr(ui.tags.th(""), ui.tags.th("Current"), ui.tags.th("With extra")),
ui.tags.tr(
ui.tags.td("Payoff date"),
ui.tags.td(current["payoff_date"].strftime("%b %Y")),
ui.tags.td(extra["payoff_date"].strftime("%b %Y") if extra else "—"),
),
ui.tags.tr(
ui.tags.td("Total months"),
ui.tags.td(years_months(current["months"])),
ui.tags.td(years_months(extra["months"]) if extra else "—"),
),
ui.tags.tr(
ui.tags.td("Total interest"),
ui.tags.td(f"${current['total_interest']:,.0f}"),
ui.tags.td(f"${extra['total_interest']:,.0f}" if extra else "—"),
),
]
if extra:
months_saved = current["months"] - extra["months"]
interest_saved = current["total_interest"] - extra["total_interest"]
rows.append(ui.tags.tr(
ui.tags.td(ui.tags.b("Savings")),
ui.tags.td(""),
ui.tags.td(ui.tags.b(f"{years_months(months_saved)}, ${interest_saved:,.0f} interest")),
))
return ui.tags.table(*rows, style="margin-top:1em; width:100%; border-collapse:collapse;")
app = App(app_ui, server)