Loan / EMI Calculator
Sliders for loan amount, interest rate and term, with a live monthly payment and a donut chart of principal vs interest.
Source
Demo.tsx
import LoanCalculator from "./LoanCalculator";
export default function Demo() {
return <LoanCalculator />;
}
LoanCalculator.module.css
.card {
--surface: #fcfcfb;
--ink: #0b0b0b;
--ink-2: #52514e;
--muted: #898781;
--track: #e1e0d9;
--border: rgba(11, 11, 11, 0.1);
--principal: #2a78d6;
--interest: #eb6834;
width: 100%;
max-width: 380px;
padding: 24px;
border: 1px solid var(--border);
border-radius: 20px;
background: var(--surface);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 12px 32px -12px rgba(0, 0, 0, 0.18);
color: var(--ink);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
@media (prefers-color-scheme: dark) {
.card {
--surface: #1a1a19;
--ink: #ffffff;
--ink-2: #c3c2b7;
--track: #2c2c2a;
--border: rgba(255, 255, 255, 0.1);
--principal: #3987e5;
--interest: #d95926;
box-shadow: 0 12px 32px -12px rgba(0, 0, 0, 0.6);
}
}
/* Monthly payment */
.hero { display: flex; flex-direction: column; gap: 2px; margin-bottom: 24px; }
.heroLabel { color: var(--ink-2); font-size: 13px; }
.heroValue { font-size: 36px; font-weight: 650; line-height: 1.1; letter-spacing: -0.02em; }
.heroUnit { margin-left: 4px; color: var(--muted); font-size: 16px; font-weight: 500; letter-spacing: 0; }
/* Sliders */
.fields { display: flex; flex-direction: column; gap: 18px; }
.fieldHead { display: flex; justify-content: space-between; margin-bottom: 6px; font-size: 14px; }
.fieldHead label { color: var(--ink-2); }
.fieldHead output { font-weight: 600; font-variant-numeric: tabular-nums; }
.range {
width: 100%;
height: 20px;
margin: 0;
background: transparent;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
}
.range::-webkit-slider-runnable-track {
height: 6px;
border-radius: 99px;
background: linear-gradient(to right, var(--ink) var(--fill), var(--track) var(--fill));
}
.range::-webkit-slider-thumb {
width: 18px;
height: 18px;
margin-top: -6px;
border: 2px solid var(--ink);
border-radius: 50%;
background: var(--surface);
-webkit-appearance: none;
transition: transform 0.15s ease;
}
.range:active::-webkit-slider-thumb { transform: scale(1.15); }
.range::-moz-range-track { height: 6px; border-radius: 99px; background: var(--track); }
.range::-moz-range-progress { height: 6px; border-radius: 99px; background: var(--ink); }
.range::-moz-range-thumb { width: 14px; height: 14px; border: 2px solid var(--ink); border-radius: 50%; background: var(--surface); }
.range:focus-visible { outline: none; }
.range:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 4px color-mix(in srgb, var(--ink) 20%, transparent); }
.range:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 4px color-mix(in srgb, var(--ink) 20%, transparent); }
/* Principal vs interest */
.breakdown {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 20px;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid var(--border);
}
.donut { position: relative; flex: none; width: 150px; height: 150px; }
.donut svg { display: block; width: 100%; height: 100%; }
.arc {
fill: none;
stroke-width: 22;
cursor: pointer;
transition: stroke-dasharray 0.3s ease, stroke-dashoffset 0.3s ease, opacity 0.15s ease;
}
.arc[data-dim="true"] { opacity: 0.3; }
.center {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
pointer-events: none;
text-align: center;
}
.centerLabel { color: var(--ink-2); font-size: 11px; }
.centerValue { font-size: 15px; font-weight: 650; }
.legend { display: flex; flex: 1; flex-direction: column; gap: 6px; min-width: 140px; margin: 0; padding: 0; list-style: none; }
.legendItem {
display: flex;
align-items: flex-start;
gap: 10px;
width: 100%;
padding: 8px;
border: 0;
border-radius: 10px;
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: default;
transition: background 0.15s ease, opacity 0.15s ease;
}
.legendItem:hover,
.legendItem:focus-visible { outline: none; background: color-mix(in srgb, var(--ink) 6%, transparent); }
.legendItem[data-dim="true"] { opacity: 0.45; }
.swatch { flex: none; width: 10px; height: 10px; margin-top: 4px; border-radius: 3px; }
.legendText { display: flex; flex-direction: column; }
.legendLabel { color: var(--ink-2); font-size: 12px; }
.legendValue { font-size: 15px; font-weight: 600; }
LoanCalculator.tsx
"use client";
import { useId, useMemo, useState, type CSSProperties } from "react";
import styles from "./LoanCalculator.module.css";
type Props = {
currency?: string;
locale?: string;
defaultAmount?: number;
/** Annual interest rate, in percent. */
defaultRate?: number;
defaultYears?: number;
};
type Segment = "principal" | "interest";
const RADIUS = 70;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
// Surface-colored gap between the two donut segments.
const GAP = 2;
/** Standard amortized monthly payment: P·r·(1+r)^n / ((1+r)^n − 1). */
export function monthlyPayment(principal: number, annualRate: number, months: number) {
const r = annualRate / 12 / 100;
if (r === 0) return principal / months;
const growth = (1 + r) ** months;
return (principal * r * growth) / (growth - 1);
}
export default function LoanCalculator({
currency = "USD",
locale = "en-US",
defaultAmount = 300_000,
defaultRate = 8.5,
defaultYears = 20,
}: Props) {
const [amount, setAmount] = useState(defaultAmount);
const [rate, setRate] = useState(defaultRate);
const [years, setYears] = useState(defaultYears);
const [active, setActive] = useState<Segment | null>(null);
const money = useMemo(
() => new Intl.NumberFormat(locale, { style: "currency", currency, maximumFractionDigits: 0 }).format,
[locale, currency],
);
const months = years * 12;
const emi = monthlyPayment(amount, rate, months);
const total = emi * months;
const interest = total - amount;
const principalShare = amount / total;
const segments = [
{ key: "principal", label: "Principal", value: amount, share: principalShare, start: 0 },
{ key: "interest", label: "Interest", value: interest, share: 1 - principalShare, start: principalShare },
] as const;
const focused = segments.find((s) => s.key === active);
return (
<div className={styles.card}>
<header className={styles.hero}>
<span className={styles.heroLabel}>Monthly payment</span>
<span className={styles.heroValue}>
{money(emi)}
<span className={styles.heroUnit}>/mo</span>
</span>
</header>
<div className={styles.fields}>
<Slider label="Loan amount" value={amount} min={5_000} max={1_000_000} step={5_000} format={money} onChange={setAmount} />
<Slider label="Interest rate" value={rate} min={0} max={20} step={0.1} format={(v) => `${v.toFixed(1)}%`} onChange={setRate} />
<Slider label="Loan term" value={years} min={1} max={30} step={1} format={(v) => `${v} ${v === 1 ? "year" : "years"}`} onChange={setYears} />
</div>
<div className={styles.breakdown}>
<div className={styles.donut}>
<svg
viewBox="0 0 180 180"
role="img"
aria-label={`Principal ${money(amount)}, interest ${money(interest)}, total ${money(total)}`}
>
<g transform="rotate(-90 90 90)">
{segments.map((s) => {
const length = Math.max(0, s.share * CIRCUMFERENCE - GAP);
return (
<circle
key={s.key}
className={styles.arc}
data-dim={active !== null && active !== s.key}
cx="90"
cy="90"
r={RADIUS}
stroke={`var(--${s.key})`}
strokeDasharray={`${length} ${CIRCUMFERENCE - length}`}
strokeDashoffset={-s.start * CIRCUMFERENCE}
onMouseEnter={() => setActive(s.key)}
onMouseLeave={() => setActive(null)}
/>
);
})}
</g>
</svg>
<div className={styles.center} aria-hidden="true">
<span className={styles.centerLabel}>{focused ? focused.label : "Total payable"}</span>
<span className={styles.centerValue}>{money(focused ? focused.value : total)}</span>
{focused && <span className={styles.centerLabel}>{Math.round(focused.share * 100)}%</span>}
</div>
</div>
<ul className={styles.legend}>
{segments.map((s) => (
<li key={s.key}>
<button
type="button"
className={styles.legendItem}
data-dim={active !== null && active !== s.key}
onMouseEnter={() => setActive(s.key)}
onMouseLeave={() => setActive(null)}
onFocus={() => setActive(s.key)}
onBlur={() => setActive(null)}
>
<span className={styles.swatch} style={{ background: `var(--${s.key})` }} />
<span className={styles.legendText}>
<span className={styles.legendLabel}>
{s.label} · {Math.round(s.share * 100)}%
</span>
<span className={styles.legendValue}>{money(s.value)}</span>
</span>
</button>
</li>
))}
</ul>
</div>
</div>
);
}
type SliderProps = {
label: string;
value: number;
min: number;
max: number;
step: number;
format: (value: number) => string;
onChange: (value: number) => void;
};
function Slider({ label, value, min, max, step, format, onChange }: SliderProps) {
const id = useId();
const fill = ((value - min) / (max - min)) * 100;
return (
<div className={styles.field}>
<div className={styles.fieldHead}>
<label htmlFor={id}>{label}</label>
<output htmlFor={id}>{format(value)}</output>
</div>
<input
id={id}
type="range"
className={styles.range}
min={min}
max={max}
step={step}
value={value}
aria-valuetext={format(value)}
onChange={(e) => onChange(Number(e.target.value))}
style={{ "--fill": `${fill}%` } as CSSProperties}
/>
</div>
);
}