Calculator Loan
Original · freeloan / mortgage calculator with sliders
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"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 & 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 & 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
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 quoteSimilar components
Browse all →
Calculator Basic
Originalbasic calculator keypad UI

Calculator Pricing
Originalinteractive pricing calculator

Calculator Tip
Originaltip and split calculator

Calculator ROI
OriginalROI / savings calculator

Calculator Unit Converter
Originalunit converter

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

App Preview Hero
OriginalA centred hero that pairs headline copy with a realistic product dashboard mock built entirely from markup, complete with browser chrome and a floating notification card.

Waitlist Capture Hero
OriginalA dark, focused hero with an inline email capture form and avatar social proof, ready for pre-launch waitlists.

Image Backdrop Hero
OriginalA cinematic full-bleed hero with a layered image-style backdrop, overlaid left-aligned copy and a trusted-by logos strip.

Gradient Mesh Hero with Staggered Text Reveal
OriginalA full-screen hero pairing a drifting animated gradient-mesh backdrop with a word-by-word staggered blur-in headline and a logo marquee under the fold.

