Price, down-payment, rate, and term sliders driving a monthly payment that digit-rolls live as inputs change.
npx shadcn@latest add @paragon/mortgage-calculatorAlso installs: digit-roll, slider
"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>
);
}