← All components
Week 1 · 2026-09-30

Loan / EMI Calculator

Sliders for loan amount, interest rate and term, with a live monthly payment and a donut chart of principal vs interest.

Monthly payment$2,603/mo
$300,000
8.5%
20 years

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>
  );
}