Finance

LoanCalculator

A loan's amount, rate and term as sliders and boxes, with the monthly payment, total interest and total paid following as they move.

#finance#loans#calculator#forms#interactive

Demo

Preview

Loan calculator

% a year
years

Monthly payment

$489.15 / month
Loan amount
$25,000.00
Total interest
$4,349.20
Payments
60
Total paid
$29,349.20

Home loan

% a year
years

Monthly payment

1.898,04 € / month
Loan amount
320.000,00 €
Total interest
363.291,35 €
Payments
360
Total paid
683.291,35 €

Move a slider: on_change gets the new payment.

Bike finance

% a year
months

Monthly payment

$200.00 / month
Loan amount
$4,800.00
Total interest
$0.00
Payments
24
Total paid
$4,800.00

Get it

It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.

$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/loan_calculator.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/loan_calculator.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/loan_calculator.qwe
component LoanCalculatorDemoLive() {    state payment = 0m    state total = 0m    <div.demo-stack>        <LoanCalculator id="loan-calculator-demo-home" name="home" label="Home loan" amount={320000} amount_least={50000} amount_most={1000000}                        amount_step={5000} rate={5.9} rate_most={12} term={30} term_least={5} term_most={35} currency="EUR" locale="de-DE"                        on_change={(q) => { payment = q.payment; total = q.total }}/>        <p.demo-note id="loan-calculator-demo-note">{payment > 0 ? `on_change: ${payment} a month, ${total} in all.` : "Move a slider: on_change gets the new payment."}</p>    </div>}view LoanCalculatorDemo() {    <div.demo-stack>        <LoanCalculator id="loan-calculator-demo-car" name="car" amount={25000} rate={6.5} term={5}/>        <LoanCalculatorDemoLive/>        <div.demo-narrow>            <LoanCalculator id="loan-calculator-demo-months" name="bike" label="Bike finance" amount={4800} amount_least={500} amount_most={10000}                            amount_step={100} rate={0} term={24} term_unit="months" term_least={6} term_most={48}/>        </div>    </div>}

Source

ui/loan_calculator.qwe
// LoanCalculator: how much a loan costs. The amount, the yearly rate and the term// each have a slider and a box to type in; the monthly payment (the annuity formula,// rounded to the cent), the total interest and the total paid follow as they move,// with a bar showing how much of what is paid is interest. Worked in decimals, from// a schedule whose last payment takes up the cents, so the totals are those a lender// would show (and AmortizationTable's for the same loan). The boxes are real// <input>s named `name` + "_amount", "_rate" and "_term", so a plain <form> sends them.////   <LoanCalculator amount={25000} rate={6.5} term={5}/>//   <LoanCalculator amount={320000} amount_most={1000000} amount_step={5000} rate={5.9} term={30} currency="EUR" locale="de-DE"//                   on_change={(q) => { payment = q.payment }}/>//   <LoanCalculator amount={18000} term={48} term_unit="months" term_least={12} term_most={84}/>//// amount, rate, term: where it starts (25,000; 6.5 % a year; 5). term_unit: "years"// (default) or "months". amount_least / amount_most / amount_step: the amount's// range and step (1,000 to 100,000 by 500); rate_least / rate_most / rate_step the// rate's (0 to 20 by 0.05); term_least / term_most the term's (1 to 30), in steps of// one. What is typed is kept to the range and the step when the box is left.// currency: ISO code ("USD"). locale: how amounts are written (the program's; en-US).// name: the prefix of the boxes' names and ids ("loan"). label: the heading, also// what screen readers call it ("Loan calculator"; null for none). on_change(q):// inside one of your components, given { amount, rate, term, months, payment,// interest, total } (decimals; term in term_unit) after each change. id, class.import ui/theme.qwe use ui_clslet loan_calculator_css = css`.ui-loan-calculator { container-type: inline-size; min-width: 0; border: 1px solid var(--ui-border); border-radius: var(--ui-radius);  background: var(--ui-surface); color: var(--ui-fg); font: 14px/1.45 var(--ui-font); }.ui-loan-calculator-grid { display: grid; gap: 0; }@container (min-width: 620px) { .ui-loan-calculator-grid { grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); } }.ui-loan-calculator-inputs { display: grid; gap: 22px; align-content: start; padding: 20px 22px 22px; }.ui-loan-calculator-title { margin: 0; font-size: 16px; font-weight: 650; }.ui-loan-calculator-field { display: grid; gap: 8px; }.ui-loan-calculator-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }.ui-loan-calculator-head label { font-size: 13px; font-weight: 500; }.ui-loan-calculator-box { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 10px; border: 1px solid var(--ui-border);  border-radius: var(--ui-radius-sm); background: var(--ui-bg); transition: border-color .15s, box-shadow .15s; }.ui-loan-calculator-box:hover { border-color: var(--ui-strong); }.ui-loan-calculator-box:focus-within { border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-loan-calculator-box input { width: 7.5em; min-width: 0; padding: 0; border: 0; outline: none; background: transparent; color: var(--ui-fg);  font: 600 15px var(--ui-font); font-variant-numeric: tabular-nums; text-align: right; }.ui-loan-calculator-box input.ui-loan-calculator-short { width: 3.6em; }.ui-loan-calculator-unit { color: var(--ui-muted); font-size: 13px; }.ui-loan-calculator-range { --ui-loan-calculator-fill: 0%; appearance: none; display: block; width: 100%; height: 22px; margin: 0;  background: transparent; cursor: pointer; }.ui-loan-calculator-range:focus { outline: none; }.ui-loan-calculator-range::-webkit-slider-runnable-track { height: 6px; border-radius: 999px; box-shadow: inset 0 0 0 1px var(--ui-border);  background: linear-gradient(to right, var(--ui-accent) var(--ui-loan-calculator-fill), var(--ui-subtle) var(--ui-loan-calculator-fill)); }.ui-loan-calculator-range::-moz-range-track { height: 6px; border-radius: 999px; background: var(--ui-subtle); box-shadow: inset 0 0 0 1px var(--ui-border); }.ui-loan-calculator-range::-moz-range-progress { height: 6px; border-radius: 999px; background: var(--ui-accent); }.ui-loan-calculator-range::-webkit-slider-thumb { appearance: none; width: 18px; height: 18px; margin-top: -6px; border-radius: 50%;  background: var(--ui-surface); border: 2px solid var(--ui-accent); box-shadow: var(--ui-shadow); }.ui-loan-calculator-range::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; background: var(--ui-surface); border: 2px solid var(--ui-accent); }.ui-loan-calculator-range:focus-visible::-webkit-slider-thumb { box-shadow: var(--ui-focus); }.ui-loan-calculator-range:focus-visible::-moz-range-thumb { box-shadow: var(--ui-focus); }.ui-loan-calculator-limits { display: flex; justify-content: space-between; margin-top: -4px; color: var(--ui-muted); font-size: 12px;  font-variant-numeric: tabular-nums; }.ui-loan-calculator-result { display: grid; gap: 14px; align-content: start; padding: 20px 22px 22px; border-top: 1px solid var(--ui-border);  background: var(--ui-subtle); border-radius: 0 0 var(--ui-radius) var(--ui-radius); }@container (min-width: 620px) { .ui-loan-calculator-result { border-top: 0; border-left: 1px solid var(--ui-border); border-radius: 0 var(--ui-radius) var(--ui-radius) 0; } }.ui-loan-calculator-caption { margin: 0; color: var(--ui-muted); font-size: 13px; font-weight: 500; }.ui-loan-calculator-payment { display: block; margin-top: -10px; font-size: 34px; font-weight: 700; letter-spacing: -.015em; line-height: 1.2;  font-variant-numeric: tabular-nums; }.ui-loan-calculator-per { color: var(--ui-muted); font-size: 14px; font-weight: 500; letter-spacing: 0; }.ui-loan-calculator-bar { display: flex; height: 10px; overflow: hidden; border-radius: 999px; background: var(--ui-warning); }.ui-loan-calculator-bar span { background: var(--ui-accent); }.ui-loan-calculator-key { display: flex; flex-wrap: wrap; gap: 4px 14px; margin: -6px 0 0; padding: 0; list-style: none; color: var(--ui-muted); font-size: 12px; }.ui-loan-calculator-key li { display: inline-flex; align-items: center; gap: 6px; }.ui-loan-calculator-key li::before { content: ""; width: 9px; height: 9px; border-radius: 2px; background: var(--ui-accent); }.ui-loan-calculator-key li + li::before { background: var(--ui-warning); }.ui-loan-calculator-totals { display: grid; grid-template-columns: 1fr auto; gap: 8px 0; margin: 0; }.ui-loan-calculator-totals dd { padding-left: 16px; }.ui-loan-calculator-totals dt { color: var(--ui-muted); }.ui-loan-calculator-totals dd { margin: 0; text-align: right; font-weight: 600; font-variant-numeric: tabular-nums; }.ui-loan-calculator-totals .ui-loan-calculator-sum { padding-top: 8px; border-top: 1px solid var(--ui-border); color: var(--ui-fg); font-weight: 650; }`// x kept between least and most, on a step counted from least.fn loan_calculator_keep(x: dec, least: dec, most: dec, step: dec) -> dec {    let snapped = step > 0 ? least + round((x - least) / step) * step : x    snapped < least ? least : (snapped > most ? most : snapped)}// x to the power n, for a whole n, exactly (pow takes no decimals).fn loan_calculator_pow(x: dec, n: int) -> dec {    let out = 1m    let i = 0    while i < n {        out = out * x        i += 1    }    out}// The monthly payment of an amount at a yearly rate in percent over a number of// months, rounded half up to the cent: amount × r / (1 − (1 + r)^−n), r a month's rate.fn loan_calculator_payment(amount: dec, rate: dec, months: int) -> dec {    if months <= 0 { return amount }    if rate <= 0 { return round(amount / months, 2, "ceiling") }    let r = rate / 1200    let f = loan_calculator_pow(1m + r, months)    round(amount * r * f / (f - 1m), 2, "half_up")}// { payment, interest, total } of the loan, from its schedule: each month's interest// rounded to the cent on what is still owed, and the last payment what is left.fn loan_calculator_totals(amount: dec, rate: dec, months: int) -> map {    let pay = loan_calculator_payment(amount, rate, months)    let r = rate / 1200    let owed = amount    let interest = 0m    let i = 1    while i <= months && owed > 0 {        let part = round(owed * r, 2, "half_up")        let principal = i == months || pay - part > owed ? owed : pay - part        interest = interest + part        owed = owed - principal        i += 1    }    { payment: pay, interest, total: amount + interest }}// An amount in the currency; whole: without the cents of a whole amount ("$25,000").fn loan_calculator_money(x: dec, currency: str, locale: str?, whole: bool = false) -> str {    let m = money.of(x, currency, "half_up")    if is_err(m) { return str(x) }    let text = locale != null ? money.format(m, locale) : money.format(m)    let units = money.minor_units(currency)    !whole || units == 0 || x != floor(x) ? text : re.replace(text, `[.,]0{${units}}(\\D*)$`, "$1")}// Text from a box as a decimal (spaces, commas and the like dropped), or null.fn loan_calculator_read(text: str) -> dec? {    let d = dec(re.replace(text, "[^0-9.]", ""))    is_err(d) ? null : d}component LoanCalculator(amount = 25000, rate = 6.5, term: int = 5, term_unit: str = "years",                         amount_least = 1000, amount_most = 100000, amount_step = 500,                         rate_least = 0, rate_most = 20, rate_step = 0.05, term_least: int = 1, term_most: int = 30,                         currency: str = "USD", locale: str? = null, name: str = "loan", label: str? = "Loan calculator",                         on_change = null, id: str? = null, class: str? = null) {    style global loan_calculator_css    let a_lo = dec(amount_least)    let a_hi = dec(amount_most)    let a_step = dec(amount_step)    let r_lo = dec(rate_least)    let r_hi = dec(rate_most)    let r_step = dec(rate_step)    state a = loan_calculator_keep(dec(amount), dec(amount_least), dec(amount_most), dec(amount_step))    state r = loan_calculator_keep(dec(rate), dec(rate_least), dec(rate_most), dec(rate_step))    state t = int(loan_calculator_keep(dec(term), dec(term_least), dec(term_most), 1m))    state a_text = str(loan_calculator_keep(dec(amount), dec(amount_least), dec(amount_most), dec(amount_step)))    state r_text = str(loan_calculator_keep(dec(rate), dec(rate_least), dec(rate_most), dec(rate_step)))    state t_text = str(loan_calculator_keep(dec(term), dec(term_least), dec(term_most), 1m))    let years = term_unit != "months"    let months = years ? t * 12 : t    let q = loan_calculator_totals(a, r, months)    let principal_share = q.total > 0 ? round(a * 100 / q.total, 1) : 100m    let base = `loan-calculator-${name}`    let unit_text = years ? (t == 1 ? "year" : "years") : (t == 1 ? "month" : "months")    let fill = (v: dec, lo: dec, hi: dec) => hi > lo ? `${round((v - lo) * 100 / (hi - lo), 2)}%` : "0%"    let changed = () => {        if on_change != null {            let m = years ? t * 12 : t            on_change(merge({ amount: a, rate: r, term: t, months: m }, loan_calculator_totals(a, r, m)))        }    }    let set_a = (x: dec?) => {        let k = x == null ? a : loan_calculator_keep(x, a_lo, a_hi, a_step)        a_text = str(k)        if k != a { a = k; changed() }    }    let set_r = (x: dec?) => {        let k = x == null ? r : loan_calculator_keep(x, r_lo, r_hi, r_step)        r_text = str(k)        if k != r { r = k; changed() }    }    let set_t = (x: dec?) => {        let k = x == null ? t : int(loan_calculator_keep(round(x), dec(term_least), dec(term_most), 1m))        t_text = str(k)        if k != t { t = k; changed() }    }    <section class={ui_cls(["ui-loan-calculator", class])} id={id} aria-label={label ?? "Loan calculator"}>        <div.ui-loan-calculator-grid>            <div.ui-loan-calculator-inputs>                {label != null && <h3.ui-loan-calculator-title>{label}</h3>}                <div.ui-loan-calculator-field>                    <div.ui-loan-calculator-head>                        <label for={`${base}-amount`}>Loan amount</label>                        <span.ui-loan-calculator-box>                            <input id={`${base}-amount`} name={`${name}_amount`} type="text" inputmode="decimal" autocomplete="off" value={a_text}                                   @input={(e) => {                                       a_text = e.value                                       let x = loan_calculator_read(e.value)                                       if x != null && x >= a_lo && x <= a_hi && x != a { a = x; changed() }                                   }}                                   @change={(e) => { set_a(loan_calculator_read(e.value)) }}/>                            <span.ui-loan-calculator-unit aria-hidden="true">{currency}</span>                        </span>                    </div>                    <input.ui-loan-calculator-range type="range" min={a_lo} max={a_hi} step={a_step} value={a} aria-label="Loan amount"                           aria-valuetext={loan_calculator_money(a, currency, locale, true)} style={`--ui-loan-calculator-fill: ${fill(a, a_lo, a_hi)}`}                           @input={(e) => { set_a(loan_calculator_read(e.value)) }}/>                    <div.ui-loan-calculator-limits aria-hidden="true">                        <span>{loan_calculator_money(a_lo, currency, locale, true)}</span><span>{loan_calculator_money(a_hi, currency, locale, true)}</span>                    </div>                </div>                <div.ui-loan-calculator-field>                    <div.ui-loan-calculator-head>                        <label for={`${base}-rate`}>Interest rate</label>                        <span.ui-loan-calculator-box>                            <input.ui-loan-calculator-short id={`${base}-rate`} name={`${name}_rate`} type="text" inputmode="decimal" autocomplete="off"                                   value={r_text} aria-describedby={`${base}-rate-unit`}                                   @input={(e) => {                                       r_text = e.value                                       let x = loan_calculator_read(e.value)                                       if x != null && x >= r_lo && x <= r_hi && x != r { r = x; changed() }                                   }}                                   @change={(e) => { set_r(loan_calculator_read(e.value)) }}/>                            <span.ui-loan-calculator-unit id={`${base}-rate-unit`}>% a year</span>                        </span>                    </div>                    <input.ui-loan-calculator-range type="range" min={r_lo} max={r_hi} step={r_step} value={r} aria-label="Interest rate"                           aria-valuetext={`${r}% a year`} style={`--ui-loan-calculator-fill: ${fill(r, r_lo, r_hi)}`}                           @input={(e) => { set_r(loan_calculator_read(e.value)) }}/>                    <div.ui-loan-calculator-limits aria-hidden="true"><span>{r_lo}%</span><span>{r_hi}%</span></div>                </div>                <div.ui-loan-calculator-field>                    <div.ui-loan-calculator-head>                        <label for={`${base}-term`}>Term</label>                        <span.ui-loan-calculator-box>                            <input.ui-loan-calculator-short id={`${base}-term`} name={`${name}_term`} type="text" inputmode="numeric" autocomplete="off"                                   value={t_text} aria-describedby={`${base}-term-unit`}                                   @input={(e) => {                                       t_text = e.value                                       let x = loan_calculator_read(e.value)                                       if x != null && x == floor(x) && x >= dec(term_least) && x <= dec(term_most) && x != t { t = x; changed() }                                   }}                                   @change={(e) => { set_t(loan_calculator_read(e.value)) }}/>                            <span.ui-loan-calculator-unit id={`${base}-term-unit`}>{unit_text}</span>                        </span>                    </div>                    <input.ui-loan-calculator-range type="range" min={term_least} max={term_most} step="1" value={t} aria-label="Term"                           aria-valuetext={`${t} ${unit_text}`} style={`--ui-loan-calculator-fill: ${fill(dec(t), dec(term_least), dec(term_most))}`}                           @input={(e) => { set_t(loan_calculator_read(e.value)) }}/>                    <div.ui-loan-calculator-limits aria-hidden="true">                        <span>{term_least} {years ? "yr" : "mo"}</span><span>{term_most} {years ? "yr" : "mo"}</span>                    </div>                </div>            </div>            <div.ui-loan-calculator-result>                <p.ui-loan-calculator-caption id={`${base}-pay-label`}>Monthly payment</p>                <output.ui-loan-calculator-payment for={`${base}-amount ${base}-rate ${base}-term`} aria-labelledby={`${base}-pay-label`}>                    {loan_calculator_money(q.payment, currency, locale)}<span.ui-loan-calculator-per> / month</span>                </output>                <div.ui-loan-calculator-bar aria-hidden="true"><span style={`width: ${principal_share}%`}></span></div>                <ul.ui-loan-calculator-key aria-hidden="true">                    <li>Principal {principal_share}%</li>                    <li>Interest {100m - principal_share}%</li>                </ul>                <dl.ui-loan-calculator-totals>                    <dt>Loan amount</dt><dd data-key="amount">{loan_calculator_money(a, currency, locale)}</dd>                    <dt>Total interest</dt><dd data-key="interest">{loan_calculator_money(q.interest, currency, locale)}</dd>                    <dt>Payments</dt><dd data-key="months">{months}</dd>                    <dt.ui-loan-calculator-sum>Total paid</dt><dd.ui-loan-calculator-sum data-key="total">{loan_calculator_money(q.total, currency, locale)}</dd>                </dl>            </div>        </div>    </section>}
Thread
75Useful? Like it, or add to its thread below.

Thread 0 posts

On LoanCalculator: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.