Data Display

Mortgage Calculator

Price, down-payment, rate, and term sliders driving a monthly payment that digit-rolls live as inputs change.

Install

npx shadcn@latest add @paragon/mortgage-calculator

Also installs: digit-roll, slider

mortgage-calculator.tsx

"use client";

import * as React from "react";
import { DigitRoll } from "@/registry/paragon/ui/digit-roll";
import { Slider } from "@/registry/paragon/ui/slider";
import { cn } from "@/lib/utils";

export interface MortgageCalculatorProps
  extends Omit<React.ComponentProps<"div">, "onChange"> {
  /** Initial home price. */
  defaultPrice?: number;
  /** Initial down payment as a percent (0–100). */
  defaultDownPercent?: number;
  /** Initial annual interest rate as a percent. */
  defaultRate?: number;
  /** Initial term in years. */
  defaultTermYears?: number;
  priceRange?: [number, number];
  rateRange?: [number, number];
  termOptions?: number[];
  /** Fires whenever any input changes, with the recomputed monthly payment. */
  onChange?: (state: {
    price: number;
    downPercent: number;
    rate: number;
    termYears: number;
    monthly: number;
  }) => void;
}

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

function computeMonthly(
  principal: number,
  annualRate: number,
  termYears: number,
) {
  const n = termYears * 12;
  if (n <= 0) return 0;
  const r = annualRate / 100 / 12;
  if (r === 0) return principal / n;
  const factor = Math.pow(1 + r, n);
  return (principal * r * factor) / (factor - 1);
}

/**
 * A mortgage calculator: price, down-payment, rate, and term sliders drive a
 * live monthly-payment figure that digit-rolls as the inputs change. The
 * amortization math runs on every commit; the DigitRoll handles reduced
 * motion (values swap in place) on its own.
 */
export function MortgageCalculator({
  defaultPrice = 650000,
  defaultDownPercent = 20,
  defaultRate = 6.5,
  defaultTermYears = 30,
  priceRange = [100000, 2000000],
  rateRange = [2, 12],
  termOptions = [15, 20, 30],
  onChange,
  className,
  ...props
}: MortgageCalculatorProps) {
  const [price, setPrice] = React.useState(defaultPrice);
  const [downPercent, setDownPercent] = React.useState(defaultDownPercent);
  const [rate, setRate] = React.useState(defaultRate);
  const [termYears, setTermYears] = React.useState(defaultTermYears);

  const down = Math.round((price * downPercent) / 100);
  const principal = Math.max(price - down, 0);
  const monthly = React.useMemo(
    () => computeMonthly(principal, rate, termYears),
    [principal, rate, termYears],
  );

  const onChangeRef = React.useRef(onChange);
  React.useEffect(() => {
    onChangeRef.current = onChange;
  });
  React.useEffect(() => {
    onChangeRef.current?.({ price, downPercent, rate, termYears, monthly });
  }, [price, downPercent, rate, termYears, monthly]);

  return (
    <div
      data-slot="mortgage-calculator"
      className={cn(
        "w-full max-w-md rounded-xl bg-card p-6 shadow-border",
        className,
      )}
      {...props}
    >
      <div className="rounded-lg bg-secondary/60 p-4 text-center">
        <p className="text-xs font-medium text-muted-foreground">
          Estimated monthly payment
        </p>
        <div className="mt-1 flex items-baseline justify-center">
          <DigitRoll
            value={Math.round(monthly)}
            formatOptions={{
              style: "currency",
              currency: "USD",
              maximumFractionDigits: 0,
            }}
            className="text-3xl font-semibold"
          />
          <span className="ml-1 text-sm text-muted-foreground">/mo</span>
        </div>
        <p className="mt-1 text-xs text-muted-foreground tabular-nums">
          {currency0.format(principal)} loan · {currency0.format(down)} down
        </p>
      </div>

      <div className="mt-6 flex flex-col gap-5">
        <Field
          label="Home price"
          value={currency0.format(price)}
          aria-label="Home price"
        >
          <Slider
            min={priceRange[0]}
            max={priceRange[1]}
            step={5000}
            value={[price]}
            onValueChange={([v]) => setPrice(v)}
            aria-label="Home price"
            formatValue={(v) => currency0.format(v)}
          />
        </Field>

        <Field
          label="Down payment"
          value={`${downPercent}% · ${currency0.format(down)}`}
        >
          <Slider
            min={0}
            max={60}
            step={1}
            value={[downPercent]}
            onValueChange={([v]) => setDownPercent(v)}
            aria-label="Down payment percent"
            formatValue={(v) => `${v}%`}
          />
        </Field>

        <Field label="Interest rate" value={`${rate.toFixed(2)}%`}>
          <Slider
            min={rateRange[0]}
            max={rateRange[1]}
            step={0.05}
            value={[rate]}
            onValueChange={([v]) => setRate(v)}
            aria-label="Interest rate"
            formatValue={(v) => `${v.toFixed(2)}%`}
          />
        </Field>

        <div>
          <div className="flex items-baseline justify-between">
            <span className="text-sm font-medium">Loan term</span>
            <span className="text-sm text-muted-foreground tabular-nums">
              {termYears} yr
            </span>
          </div>
          <div
            role="radiogroup"
            aria-label="Loan term in years"
            className="mt-2 grid grid-cols-3 gap-2"
          >
            {termOptions.map((years) => {
              const active = years === termYears;
              return (
                <button
                  key={years}
                  type="button"
                  role="radio"
                  aria-checked={active}
                  onClick={() => setTermYears(years)}
                  className={cn(
                    "pressable rounded-lg py-2 text-sm font-medium tabular-nums outline-none transition-[background-color,box-shadow,color] duration-150 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
                    active
                      ? "bg-primary text-primary-foreground"
                      : "bg-secondary text-secondary-foreground hover:bg-secondary/70",
                  )}
                >
                  {years} yr
                </button>
              );
            })}
          </div>
        </div>
      </div>
    </div>
  );
}

function Field({
  label,
  value,
  children,
}: {
  label: string;
  value: string;
  children: React.ReactNode;
}) {
  return (
    <div>
      <div className="flex items-baseline justify-between">
        <span className="text-sm font-medium">{label}</span>
        <span className="text-sm text-muted-foreground tabular-nums">
          {value}
        </span>
      </div>
      <div className="mt-2">{children}</div>
    </div>
  );
}