Web InnoventixFreeCode

Calculator Loan

Original · free

loan / mortgage calculator with sliders

byWeb InnoventixReact + Tailwind
calcloancalculators
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/calc-loan.json
calc-loan.tsx
"use client";

import {
  useEffect,
  useId,
  useMemo,
  useState,
  type CSSProperties,
  type ReactNode,
} from "react";
import {
  motion,
  useMotionValueEvent,
  useReducedMotion,
  useSpring,
} from "motion/react";

const TERMS = [10, 15, 20, 25, 30] as const;

const PROPERTY_TAX_RATE = 0.011;
const ANNUAL_INSURANCE = 1400;

const money0 = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
  maximumFractionDigits: 0,
});

const money2 = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
  minimumFractionDigits: 2,
  maximumFractionDigits: 2,
});

type Schedule = {
  payment: number;
  months: number;
  totalInterest: number;
  yearlyBalance: number[];
};

function amortize(
  principal: number,
  annualRate: number,
  years: number,
  extra: number,
): Schedule {
  const n = Math.round(years * 12);
  if (principal <= 0 || n <= 0) {
    return { payment: 0, months: 0, totalInterest: 0, yearlyBalance: [0] };
  }

  const r = annualRate / 100 / 12;
  const payment =
    r === 0
      ? principal / n
      : (principal * r) / (1 - Math.pow(1 + r, -n));

  let balance = principal;
  let totalInterest = 0;
  let months = 0;
  const yearlyBalance: number[] = [principal];

  for (let m = 1; m <= n; m += 1) {
    const interest = balance * r;
    let principalPart = payment + extra - interest;
    if (principalPart <= 0) principalPart = 0;
    if (principalPart >= balance) principalPart = balance;

    balance -= principalPart;
    totalInterest += interest;
    months = m;

    if (m % 12 === 0) yearlyBalance.push(balance);
    if (balance <= 0.005) break;
  }

  while (yearlyBalance.length < years + 1) yearlyBalance.push(Math.max(balance, 0));

  return { payment, months, totalInterest, yearlyBalance };
}

function linePath(values: number[], w: number, h: number, max: number): string {
  if (values.length < 2 || max <= 0) return "";
  const step = w / (values.length - 1);
  return values
    .map((v, i) => {
      const x = (i * step).toFixed(2);
      const y = (h - (Math.max(v, 0) / max) * h).toFixed(2);
      return `${i === 0 ? "M" : "L"} ${x} ${y}`;
    })
    .join(" ");
}

function durationLabel(months: number): string {
  const y = Math.floor(months / 12);
  const m = months % 12;
  if (y === 0) return `${m} mo`;
  if (m === 0) return `${y} yr`;
  return `${y} yr ${m} mo`;
}

function useAnimatedNumber(value: number, instant: boolean): number {
  const spring = useSpring(value, { stiffness: 150, damping: 24, mass: 0.6 });
  const [display, setDisplay] = useState(value);

  useEffect(() => {
    spring.set(value);
  }, [spring, value]);

  useMotionValueEvent(spring, "change", (v: number) => setDisplay(v));

  return instant ? value : display;
}

type SliderProps = {
  label: string;
  hint: string;
  value: number;
  min: number;
  max: number;
  step: number;
  valueText: string;
  readout: ReactNode;
  onChange: (v: number) => void;
};

function Slider({
  label,
  hint,
  value,
  min,
  max,
  step,
  valueText,
  readout,
  onChange,
}: SliderProps) {
  const id = useId();
  const pct = max === min ? 0 : ((value - min) / (max - min)) * 100;

  return (
    <div>
      <div className="flex items-baseline justify-between gap-4">
        <label
          htmlFor={id}
          className="text-sm font-medium text-slate-700 dark:text-slate-200"
        >
          {label}
        </label>
        <div className="text-right tabular-nums">{readout}</div>
      </div>

      <input
        id={id}
        type="range"
        className="wxlc-range mt-2"
        min={min}
        max={max}
        step={step}
        value={value}
        aria-valuetext={valueText}
        aria-describedby={`${id}-hint`}
        onChange={(e) => onChange(Number(e.target.value))}
        style={{ "--wxlc-fill": `${pct}%` } as CSSProperties}
      />

      <p
        id={`${id}-hint`}
        className="mt-1 text-xs text-slate-500 dark:text-slate-400"
      >
        {hint}
      </p>
    </div>
  );
}

export default function CalcLoan() {
  const reduced = useReducedMotion() ?? false;

  const [price, setPrice] = useState(465000);
  const [downPct, setDownPct] = useState(15);
  const [rate, setRate] = useState(6.35);
  const [term, setTerm] = useState<number>(30);
  const [extra, setExtra] = useState(0);
  const [withEscrow, setWithEscrow] = useState(true);

  const termName = useId();
  const chartTitleId = useId();

  const down = Math.round((price * downPct) / 100);
  const principal = Math.max(price - down, 0);

  const base = useMemo(
    () => amortize(principal, rate, term, 0),
    [principal, rate, term],
  );
  const plan = useMemo(
    () => amortize(principal, rate, term, extra),
    [principal, rate, term, extra],
  );

  const tax = withEscrow ? (price * PROPERTY_TAX_RATE) / 12 : 0;
  const insurance = withEscrow ? ANNUAL_INSURANCE / 12 : 0;
  const monthlyTotal = base.payment + extra + tax + insurance;

  const interestSaved = base.totalInterest - plan.totalInterest;
  const monthsSaved = base.months - plan.months;
  const totalOfLoan = principal + plan.totalInterest;
  const principalShare = totalOfLoan > 0 ? principal / totalOfLoan : 1;

  const animatedTotal = useAnimatedNumber(monthlyTotal, reduced);
  const animatedInterest = useAnimatedNumber(plan.totalInterest, reduced);

  const W = 320;
  const H = 96;
  const chartMax = Math.max(principal, 1);
  const basePath = linePath(base.yearlyBalance, W, H, chartMax);
  const planPath = linePath(plan.yearlyBalance, W, H, chartMax);
  const planArea = planPath ? `${planPath} L ${W} ${H} L 0 ${H} Z` : "";

  const R = 52;
  const CIRC = 2 * Math.PI * R;
  const principalDash = CIRC * principalShare;

  const ltv = price > 0 ? (principal / price) * 100 : 0;

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-6 py-20 sm:py-28 dark:bg-slate-950">
      <style>{`
        @keyframes wxlc-sheen {
          0% { transform: translateX(-120%) skewX(-18deg); }
          100% { transform: translateX(320%) skewX(-18deg); }
        }
        @keyframes wxlc-pulse-ring {
          0%, 100% { opacity: 0.55; }
          50% { opacity: 0.15; }
        }
        .wxlc-sheen { animation: wxlc-sheen 5.5s ease-in-out infinite; }
        .wxlc-pulse { animation: wxlc-pulse-ring 4s ease-in-out infinite; }

        .wxlc-range {
          -webkit-appearance: none;
          appearance: none;
          width: 100%;
          height: 1.5rem;
          background: transparent;
          cursor: pointer;
        }
        .wxlc-range:focus { outline: none; }
        .wxlc-range::-webkit-slider-runnable-track {
          height: 0.5rem;
          border-radius: 9999px;
          background: linear-gradient(
            to right,
            var(--wxlc-a) 0%,
            var(--wxlc-b) var(--wxlc-fill),
            var(--wxlc-track) var(--wxlc-fill),
            var(--wxlc-track) 100%
          );
        }
        .wxlc-range::-webkit-slider-thumb {
          -webkit-appearance: none;
          appearance: none;
          height: 1.25rem;
          width: 1.25rem;
          margin-top: -0.375rem;
          border-radius: 9999px;
          background: var(--wxlc-thumb);
          border: 2px solid var(--wxlc-b);
          box-shadow: 0 1px 6px rgba(15, 23, 42, 0.3);
          transition: transform 0.15s ease, box-shadow 0.15s ease;
        }
        .wxlc-range:active::-webkit-slider-thumb { transform: scale(1.14); }
        .wxlc-range:focus-visible::-webkit-slider-thumb {
          box-shadow: 0 0 0 4px var(--wxlc-ring);
        }
        .wxlc-range::-moz-range-track {
          height: 0.5rem;
          border-radius: 9999px;
          background: var(--wxlc-track);
        }
        .wxlc-range::-moz-range-progress {
          height: 0.5rem;
          border-radius: 9999px;
          background: var(--wxlc-b);
        }
        .wxlc-range::-moz-range-thumb {
          height: 1.15rem;
          width: 1.15rem;
          border-radius: 9999px;
          background: var(--wxlc-thumb);
          border: 2px solid var(--wxlc-b);
          box-shadow: 0 1px 6px rgba(15, 23, 42, 0.3);
          transition: transform 0.15s ease, box-shadow 0.15s ease;
        }
        .wxlc-range:active::-moz-range-thumb { transform: scale(1.14); }
        .wxlc-range:focus-visible::-moz-range-thumb {
          box-shadow: 0 0 0 4px var(--wxlc-ring);
        }

        @media (prefers-reduced-motion: reduce) {
          .wxlc-sheen, .wxlc-pulse { animation: none !important; }
          .wxlc-range::-webkit-slider-thumb,
          .wxlc-range::-moz-range-thumb { transition: none !important; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute -top-32 left-1/2 h-80 w-[46rem] -translate-x-1/2 rounded-full bg-indigo-300/30 blur-3xl dark:bg-indigo-600/15"
      />
      <div
        aria-hidden="true"
        className="pointer-events-none absolute -bottom-40 right-[-8rem] h-80 w-80 rounded-full bg-emerald-300/25 blur-3xl dark:bg-emerald-500/10"
      />

      <div
        className={[
          "wxlc-scope relative mx-auto max-w-6xl",
          "[--wxlc-a:#a5b4fc] [--wxlc-b:#6366f1] [--wxlc-track:#e2e8f0] [--wxlc-thumb:#ffffff] [--wxlc-ring:rgba(99,102,241,0.35)]",
          "dark:[--wxlc-a:#818cf8] dark:[--wxlc-b:#a5b4fc] dark:[--wxlc-track:#334155] dark:[--wxlc-thumb:#f1f5f9] dark:[--wxlc-ring:rgba(165,180,252,0.35)]",
        ].join(" ")}
      >
        <div className="max-w-2xl">
          <span className="inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-white/70 px-3 py-1 text-xs font-medium text-indigo-700 backdrop-blur dark:border-indigo-400/30 dark:bg-slate-900/70 dark:text-indigo-300">
            <svg
              viewBox="0 0 20 20"
              className="h-3.5 w-3.5"
              fill="none"
              stroke="currentColor"
              strokeWidth="1.7"
              aria-hidden="true"
            >
              <rect x="3.5" y="2.5" width="13" height="15" rx="2.5" />
              <path d="M6.5 6.5h7M6.5 10h2M6.5 13.5h2M12 10v3.5M10.5 11.75h3" strokeLinecap="round" />
            </svg>
            Amortisation, not guesswork
          </span>

          <h2 className="mt-5 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
            Know the monthly number before a lender tells you.
          </h2>
          <p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-400">
            Drag the sliders to model price, deposit, rate and term. Every figure
            below comes from a full month-by-month amortisation run — including
            what an extra payment actually claws back in interest.
          </p>
        </div>

        <div className="mt-10 grid gap-6 lg:grid-cols-[1.05fr_1fr]">
          {/* Controls */}
          <div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8 dark:border-slate-800 dark:bg-slate-900">
            <div className="space-y-7">
              <Slider
                label="Property price"
                hint="Agreed purchase price, before fees."
                value={price}
                min={60000}
                max={2500000}
                step={5000}
                valueText={money0.format(price)}
                onChange={setPrice}
                readout={
                  <span className="text-base font-semibold text-slate-900 dark:text-white">
                    {money0.format(price)}
                  </span>
                }
              />

              <Slider
                label="Deposit"
                hint={`Borrowing ${money0.format(principal)} — ${ltv.toFixed(0)}% loan-to-value.`}
                value={downPct}
                min={0}
                max={60}
                step={0.5}
                valueText={`${downPct} percent, ${money0.format(down)}`}
                onChange={setDownPct}
                readout={
                  <>
                    <span className="text-base font-semibold text-slate-900 dark:text-white">
                      {downPct.toFixed(1)}%
                    </span>
                    <span className="ml-2 text-sm text-slate-500 dark:text-slate-400">
                      {money0.format(down)}
                    </span>
                  </>
                }
              />

              <Slider
                label="Interest rate"
                hint="Fixed nominal APR, compounded monthly."
                value={rate}
                min={0.5}
                max={12}
                step={0.05}
                valueText={`${rate.toFixed(2)} percent`}
                onChange={setRate}
                readout={
                  <span className="text-base font-semibold text-slate-900 dark:text-white">
                    {rate.toFixed(2)}%
                  </span>
                }
              />

              <fieldset>
                <legend className="text-sm font-medium text-slate-700 dark:text-slate-200">
                  Term
                </legend>
                <div className="mt-2 grid grid-cols-5 gap-2">
                  {TERMS.map((t) => (
                    <div key={t} className="relative">
                      <input
                        type="radio"
                        id={`${termName}-${t}`}
                        name={termName}
                        className="peer sr-only"
                        checked={term === t}
                        onChange={() => setTerm(t)}
                      />
                      <label
                        htmlFor={`${termName}-${t}`}
                        className="flex cursor-pointer select-none flex-col items-center rounded-lg border border-slate-200 bg-slate-50 py-2 text-sm font-medium text-slate-600 transition-colors hover:border-indigo-300 hover:text-slate-900 peer-checked:border-indigo-500 peer-checked:bg-indigo-500 peer-checked:text-white peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300 dark:hover:border-indigo-400/60 dark:hover:text-white dark:peer-checked:border-indigo-400 dark:peer-checked:bg-indigo-500 dark:peer-focus-visible:ring-indigo-400 dark:peer-focus-visible:ring-offset-slate-900"
                      >
                        {t}
                        <span className="text-[0.65rem] font-normal opacity-70">
                          yrs
                        </span>
                      </label>
                    </div>
                  ))}
                </div>
              </fieldset>

              <Slider
                label="Extra monthly payment"
                hint="Overpayment applied straight to the principal each month."
                value={extra}
                min={0}
                max={2000}
                step={25}
                valueText={`${money0.format(extra)} per month`}
                onChange={setExtra}
                readout={
                  <span className="text-base font-semibold text-slate-900 dark:text-white">
                    {extra === 0 ? "None" : `+${money0.format(extra)}`}
                  </span>
                }
              />

              <div className="flex items-start justify-between gap-4 rounded-xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-800/40">
                <div>
                  <p className="text-sm font-medium text-slate-800 dark:text-slate-100">
                    Include tax &amp; insurance
                  </p>
                  <p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">
                    Estimates at 1.1% of price per year plus $1,400 cover.
                  </p>
                </div>
                <button
                  type="button"
                  role="switch"
                  aria-checked={withEscrow}
                  aria-label="Include property tax and insurance in the monthly total"
                  onClick={() => setWithEscrow((v) => !v)}
                  className={[
                    "relative mt-0.5 h-6 w-11 shrink-0 rounded-full transition-colors",
                    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-900",
                    withEscrow
                      ? "bg-indigo-500"
                      : "bg-slate-300 dark:bg-slate-600",
                  ].join(" ")}
                >
                  <motion.span
                    aria-hidden="true"
                    className="absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white shadow"
                    animate={{ x: withEscrow ? 20 : 0 }}
                    transition={
                      reduced
                        ? { duration: 0 }
                        : { type: "spring", stiffness: 480, damping: 32 }
                    }
                  />
                </button>
              </div>
            </div>
          </div>

          {/* Results */}
          <div className="space-y-6">
            <div className="relative overflow-hidden rounded-2xl border border-indigo-500/30 bg-slate-900 p-6 shadow-lg sm:p-8 dark:border-indigo-400/25">
              <div
                aria-hidden="true"
                className="pointer-events-none absolute inset-0 overflow-hidden"
              >
                <div className="wxlc-sheen absolute inset-y-0 -left-1/3 w-1/3 bg-gradient-to-r from-transparent via-white/10 to-transparent" />
              </div>

              <p className="relative text-xs font-medium uppercase tracking-wider text-indigo-300">
                Monthly payment
              </p>
              <p className="relative mt-2 text-4xl font-semibold tabular-nums tracking-tight text-white sm:text-5xl">
                {money2.format(animatedTotal)}
              </p>
              <p className="relative mt-2 text-sm text-slate-400">
                {money0.format(principal)} over {term} years at {rate.toFixed(2)}%
              </p>

              <dl className="relative mt-6 space-y-2 border-t border-white/10 pt-5 text-sm">
                <div className="flex justify-between gap-4">
                  <dt className="text-slate-400">Principal &amp; interest</dt>
                  <dd className="tabular-nums font-medium text-slate-100">
                    {money2.format(base.payment)}
                  </dd>
                </div>
                {extra > 0 && (
                  <div className="flex justify-between gap-4">
                    <dt className="text-slate-400">Overpayment</dt>
                    <dd className="tabular-nums font-medium text-emerald-300">
                      {money2.format(extra)}
                    </dd>
                  </div>
                )}
                {withEscrow && (
                  <>
                    <div className="flex justify-between gap-4">
                      <dt className="text-slate-400">Property tax (est.)</dt>
                      <dd className="tabular-nums font-medium text-slate-100">
                        {money2.format(tax)}
                      </dd>
                    </div>
                    <div className="flex justify-between gap-4">
                      <dt className="text-slate-400">Insurance (est.)</dt>
                      <dd className="tabular-nums font-medium text-slate-100">
                        {money2.format(insurance)}
                      </dd>
                    </div>
                  </>
                )}
              </dl>
            </div>

            <div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8 dark:border-slate-800 dark:bg-slate-900">
              <div className="flex flex-col items-center gap-6 sm:flex-row sm:gap-8">
                <div className="relative shrink-0">
                  <svg
                    viewBox="0 0 128 128"
                    className="h-32 w-32 -rotate-90"
                    role="img"
                    aria-label={`Of the ${money0.format(totalOfLoan)} repaid, ${money0.format(principal)} is principal and ${money0.format(plan.totalInterest)} is interest.`}
                  >
                    <circle
                      cx="64"
                      cy="64"
                      r={R}
                      fill="none"
                      strokeWidth="14"
                      className="stroke-rose-200 dark:stroke-rose-500/40"
                    />
                    <motion.circle
                      cx="64"
                      cy="64"
                      r={R}
                      fill="none"
                      strokeWidth="14"
                      strokeLinecap="round"
                      className="stroke-indigo-500 dark:stroke-indigo-400"
                      strokeDasharray={CIRC}
                      animate={{ strokeDashoffset: CIRC - principalDash }}
                      transition={
                        reduced
                          ? { duration: 0 }
                          : { type: "spring", stiffness: 90, damping: 20 }
                      }
                    />
                  </svg>
                  <div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
                    <span className="text-lg font-semibold tabular-nums text-slate-900 dark:text-white">
                      {(principalShare * 100).toFixed(0)}%
                    </span>
                    <span className="text-[0.65rem] uppercase tracking-wide text-slate-500 dark:text-slate-400">
                      principal
                    </span>
                  </div>
                </div>

                <dl className="w-full space-y-3 text-sm">
                  <div className="flex items-center justify-between gap-4">
                    <dt className="flex items-center gap-2 text-slate-600 dark:text-slate-400">
                      <span className="h-2.5 w-2.5 rounded-full bg-indigo-500 dark:bg-indigo-400" />
                      Amount borrowed
                    </dt>
                    <dd className="tabular-nums font-semibold text-slate-900 dark:text-white">
                      {money0.format(principal)}
                    </dd>
                  </div>
                  <div className="flex items-center justify-between gap-4">
                    <dt className="flex items-center gap-2 text-slate-600 dark:text-slate-400">
                      <span className="h-2.5 w-2.5 rounded-full bg-rose-300 dark:bg-rose-500/60" />
                      Interest paid
                    </dt>
                    <dd className="tabular-nums font-semibold text-slate-900 dark:text-white">
                      {money0.format(animatedInterest)}
                    </dd>
                  </div>
                  <div className="flex items-center justify-between gap-4 border-t border-slate-200 pt-3 dark:border-slate-800">
                    <dt className="text-slate-600 dark:text-slate-400">
                      Total repaid
                    </dt>
                    <dd className="tabular-nums font-semibold text-slate-900 dark:text-white">
                      {money0.format(totalOfLoan)}
                    </dd>
                  </div>
                  <div className="flex items-center justify-between gap-4">
                    <dt className="text-slate-600 dark:text-slate-400">
                      Paid off in
                    </dt>
                    <dd className="tabular-nums font-semibold text-slate-900 dark:text-white">
                      {durationLabel(plan.months)}
                    </dd>
                  </div>
                </dl>
              </div>

              <figure className="mt-7 border-t border-slate-200 pt-6 dark:border-slate-800">
                <figcaption
                  id={chartTitleId}
                  className="mb-3 flex flex-wrap items-center justify-between gap-2 text-xs"
                >
                  <span className="font-medium text-slate-700 dark:text-slate-200">
                    Balance remaining
                  </span>
                  <span className="flex items-center gap-3 text-slate-500 dark:text-slate-400">
                    <span className="flex items-center gap-1.5">
                      <span className="h-0.5 w-4 rounded bg-indigo-500 dark:bg-indigo-400" />
                      Your plan
                    </span>
                    {extra > 0 && (
                      <span className="flex items-center gap-1.5">
                        <span className="h-0.5 w-4 rounded bg-slate-300 dark:bg-slate-600" />
                        No overpayment
                      </span>
                    )}
                  </span>
                </figcaption>

                <svg
                  viewBox={`0 0 ${W} ${H}`}
                  className="h-24 w-full"
                  preserveAspectRatio="none"
                  aria-hidden="true"
                  focusable="false"
                >
                  <defs>
                    <linearGradient id="wxlc-fill-grad" x1="0" y1="0" x2="0" y2="1">
                      <stop
                        offset="0%"
                        className="text-indigo-500/35 dark:text-indigo-400/30"
                        stopColor="currentColor"
                      />
                      <stop
                        offset="100%"
                        className="text-indigo-500/0"
                        stopColor="currentColor"
                      />
                    </linearGradient>
                  </defs>

                  {[0.25, 0.5, 0.75].map((g) => (
                    <line
                      key={g}
                      x1="0"
                      x2={W}
                      y1={H * g}
                      y2={H * g}
                      strokeWidth="1"
                      strokeDasharray="3 4"
                      className="stroke-slate-200 dark:stroke-slate-800"
                    />
                  ))}

                  {planArea && <path d={planArea} fill="url(#wxlc-fill-grad)" />}
                  {extra > 0 && basePath && (
                    <path
                      d={basePath}
                      fill="none"
                      strokeWidth="2"
                      strokeDasharray="4 4"
                      vectorEffect="non-scaling-stroke"
                      className="stroke-slate-300 dark:stroke-slate-600"
                    />
                  )}
                  {planPath && (
                    <path
                      d={planPath}
                      fill="none"
                      strokeWidth="2.5"
                      strokeLinecap="round"
                      vectorEffect="non-scaling-stroke"
                      className="stroke-indigo-500 dark:stroke-indigo-400"
                    />
                  )}
                </svg>

                <div className="mt-1.5 flex justify-between text-[0.65rem] text-slate-400 dark:text-slate-500">
                  <span>Year 0</span>
                  <span>Year {term}</span>
                </div>

                <p className="sr-only">
                  The outstanding balance starts at {money0.format(principal)} and
                  reaches zero after {durationLabel(plan.months)}.
                </p>
              </figure>
            </div>

            <div
              className={[
                "rounded-2xl border p-5 transition-colors",
                extra > 0
                  ? "border-emerald-300 bg-emerald-50 dark:border-emerald-500/30 dark:bg-emerald-500/10"
                  : "border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900",
              ].join(" ")}
            >
              {extra > 0 ? (
                <div className="flex items-start gap-3">
                  <svg
                    viewBox="0 0 20 20"
                    className="wxlc-pulse mt-0.5 h-5 w-5 shrink-0 text-emerald-600 dark:text-emerald-400"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth="1.7"
                    aria-hidden="true"
                  >
                    <path
                      d="M3 13.5 8 8.5l3 3 5.5-5.5"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                    />
                    <path d="M12.5 6h4v4" strokeLinecap="round" strokeLinejoin="round" />
                  </svg>
                  <p className="text-sm leading-relaxed text-emerald-900 dark:text-emerald-200">
                    Paying {money0.format(extra)} extra each month saves{" "}
                    <strong className="font-semibold tabular-nums">
                      {money0.format(interestSaved)}
                    </strong>{" "}
                    in interest and clears the loan{" "}
                    <strong className="font-semibold">
                      {durationLabel(monthsSaved)}
                    </strong>{" "}
                    early.
                  </p>
                </div>
              ) : (
                <p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400">
                  Nudge the overpayment slider — even {money0.format(100)} a month
                  changes the payoff date noticeably on a {term}-year term.
                </p>
              )}
            </div>

            <p className="text-xs leading-relaxed text-slate-500 dark:text-slate-500">
              Illustrative only. Assumes a fixed rate for the full term and level
              payments; lender fees, mortgage insurance and rate changes are not
              modelled.
            </p>
          </div>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →