Calculator ROI
Original · freeROI / savings calculator
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-roi.json"use client";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type ChangeEvent,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { motion, useReducedMotion } from "motion/react";
/* ------------------------------------------------------------------ types */
type CurrencyCode = "USD" | "EUR" | "GBP" | "INR";
type Currency = {
code: CurrencyCode;
label: string;
short: string;
locale: string;
};
type FieldKey =
| "people"
| "hoursPerWeek"
| "hourlyCost"
| "automationPct"
| "seatCost"
| "setupCost";
type Inputs = Record<FieldKey, number>;
type Unit = "count" | "hours" | "money" | "pct";
type FieldConfig = {
key: FieldKey;
label: string;
hint: string;
min: number;
max: number;
step: number;
unit: Unit;
};
/* ------------------------------------------------------------- constants */
const CURRENCIES: readonly Currency[] = [
{ code: "USD", label: "US dollar", short: "USD", locale: "en-US" },
{ code: "EUR", label: "Euro", short: "EUR", locale: "de-DE" },
{ code: "GBP", label: "Pound sterling", short: "GBP", locale: "en-GB" },
{ code: "INR", label: "Indian rupee", short: "INR", locale: "en-IN" },
];
const DEFAULTS: Inputs = {
people: 8,
hoursPerWeek: 4,
hourlyCost: 55,
automationPct: 60,
seatCost: 39,
setupCost: 18000,
};
const FIELDS: readonly FieldConfig[] = [
{
key: "people",
label: "People doing this work",
hint: "Everyone who touches the workflow today, not headcount for the whole team.",
min: 1,
max: 200,
step: 1,
unit: "count",
},
{
key: "hoursPerWeek",
label: "Hours per person, per week",
hint: "Time each person loses to the manual steps — chasing, copying, re-checking.",
min: 0.5,
max: 40,
step: 0.5,
unit: "hours",
},
{
key: "hourlyCost",
label: "Fully loaded hourly cost",
hint: "Salary plus tax, benefits and overhead — usually 1.25–1.4x base pay.",
min: 10,
max: 400,
step: 1,
unit: "money",
},
{
key: "automationPct",
label: "Share of the work automated",
hint: "Be honest here. Most rollouts land between 50% and 75%, not 100%.",
min: 5,
max: 95,
step: 5,
unit: "pct",
},
{
key: "seatCost",
label: "Tool cost per person, per month",
hint: "The per-seat licence you would actually pay after any annual discount.",
min: 0,
max: 300,
step: 1,
unit: "money",
},
{
key: "setupCost",
label: "One-time setup & migration",
hint: "Implementation, data migration, training — the cheque you write once.",
min: 0,
max: 60000,
step: 100,
unit: "money",
},
];
const WEEKS_PER_MONTH = 52 / 12;
const HORIZON = 12;
const EASE: [number, number, number, number] = [0.22, 1, 0.36, 1];
/* ----------------------------------------------------------------- icons */
function BoltIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-4 w-4">
<path
d="M13 2 4.5 13.5H11l-1 8.5 8.5-11.5H12l1-8.5Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
);
}
function CopyIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-4 w-4">
<rect
x="9"
y="9"
width="11"
height="11"
rx="2.5"
stroke="currentColor"
strokeWidth="1.6"
/>
<path
d="M15 5.5A2.5 2.5 0 0 0 12.5 3h-6A3.5 3.5 0 0 0 3 6.5v6A2.5 2.5 0 0 0 5.5 15"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
);
}
function CheckIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-4 w-4">
<path
d="m4.5 12.5 5 5 10-11"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function ResetIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-4 w-4">
<path
d="M4 11a8 8 0 1 1 2.3 5.7"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<path
d="M4 4.5V11h6.5"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function WarnIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="mt-0.5 h-4 w-4 shrink-0">
<path
d="M12 3.8 21 19.5H3L12 3.8Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<path d="M12 10v4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
<circle cx="12" cy="17" r="0.9" fill="currentColor" />
</svg>
);
}
/* ------------------------------------------------------------------ hooks */
function useCountUp(value: number, animate: boolean): number {
const [display, setDisplay] = useState<number>(value);
const fromRef = useRef<number>(value);
const rafRef = useRef<number | null>(null);
useEffect(() => {
if (!animate) {
fromRef.current = value;
return;
}
const from = fromRef.current;
const delta = value - from;
const started = performance.now();
const duration = 420;
const tick = (now: number) => {
const t = Math.min(1, (now - started) / duration);
const eased = 1 - Math.pow(1 - t, 3);
const current = from + delta * eased;
fromRef.current = current;
setDisplay(current);
if (t < 1) {
rafRef.current = requestAnimationFrame(tick);
} else {
fromRef.current = value;
}
};
rafRef.current = requestAnimationFrame(tick);
return () => {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
};
}, [value, animate]);
return animate ? display : value;
}
/* ------------------------------------------------------------- sub-parts */
type SliderFieldProps = {
config: FieldConfig;
value: number;
display: string;
onChange: (next: number) => void;
};
function SliderField({ config, value, display, onChange }: SliderFieldProps) {
const { key, label, hint, min, max, step } = config;
const sliderId = `calcroi-${key}`;
const boxId = `calcroi-${key}-box`;
const hintId = `calcroi-${key}-hint`;
// While the box has focus we keep the literal keystrokes, so half-typed values
// like "12." survive a re-render. Blur hands control back to the slider.
const [draft, setDraft] = useState<string | null>(null);
const shown = draft ?? String(value);
const clamp = useCallback(
(n: number) => Math.min(max, Math.max(min, n)),
[min, max],
);
const onSlider = useCallback(
(e: ChangeEvent<HTMLInputElement>) => onChange(clamp(Number(e.target.value))),
[onChange, clamp],
);
const onBox = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const next = e.target.value;
setDraft(next);
const n = Number(next);
if (next.trim() !== "" && Number.isFinite(n)) onChange(clamp(n));
},
[onChange, clamp],
);
const onBoxBlur = useCallback(() => {
const n = Number(shown);
const settled = shown.trim() === "" || !Number.isFinite(n) ? min : clamp(n);
onChange(settled);
setDraft(null);
}, [shown, min, clamp, onChange]);
const pct = ((value - min) / (max - min)) * 100;
const fill = { ["--calcroi-fill"]: `${pct}%` } as CSSProperties;
return (
<div className="py-5">
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<label
htmlFor={sliderId}
className="text-sm font-medium text-slate-800 dark:text-slate-100"
>
{label}
</label>
<div className="flex items-center gap-2">
<span
aria-hidden="true"
className="text-sm tabular-nums text-slate-500 dark:text-slate-400"
>
{display}
</span>
<input
id={boxId}
type="number"
inputMode="decimal"
min={min}
max={max}
step={step}
value={shown}
onChange={onBox}
onBlur={onBoxBlur}
aria-label={`${label} — type an exact value`}
aria-describedby={hintId}
className="w-20 rounded-lg border border-slate-300 bg-white px-2 py-1 text-right text-sm tabular-nums text-slate-900 shadow-sm outline-none transition focus-visible:border-indigo-500 focus-visible:ring-2 focus-visible:ring-indigo-500/40 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:focus-visible:border-indigo-400 dark:focus-visible:ring-indigo-400/40"
/>
</div>
</div>
<input
id={sliderId}
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={onSlider}
aria-describedby={hintId}
style={fill}
className="calcroi-range mt-3 h-5 w-full cursor-pointer rounded-full"
/>
<p id={hintId} className="mt-2 text-xs leading-relaxed text-slate-500 dark:text-slate-400">
{hint}
</p>
</div>
);
}
type StatProps = {
label: string;
value: string;
tone: "neutral" | "good" | "bad";
note: string;
};
function Stat({ label, value, tone, note }: StatProps) {
const toneClass =
tone === "good"
? "text-emerald-600 dark:text-emerald-400"
: tone === "bad"
? "text-rose-600 dark:text-rose-400"
: "text-slate-900 dark:text-slate-50";
return (
<div className="rounded-xl border border-slate-200 bg-white/70 p-4 dark:border-slate-800 dark:bg-slate-900/60">
<dt className="text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
{label}
</dt>
<dd className={`mt-1.5 text-xl font-semibold tabular-nums ${toneClass}`}>{value}</dd>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">{note}</p>
</div>
);
}
/* ------------------------------------------------------------- component */
export default function CalcRoi() {
const prefersReduced = useReducedMotion();
const reduce = prefersReduced ?? false;
const [inputs, setInputs] = useState<Inputs>(DEFAULTS);
const [currencyCode, setCurrencyCode] = useState<CurrencyCode>("USD");
const [copied, setCopied] = useState<boolean>(false);
const [live, setLive] = useState<string>("");
const copyTimer = useRef<number | null>(null);
const currencyRefs = useRef<Array<HTMLButtonElement | null>>([]);
useEffect(
() => () => {
if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
},
[],
);
const currency = useMemo<Currency>(
() => CURRENCIES.find((c) => c.code === currencyCode) ?? CURRENCIES[0]!,
[currencyCode],
);
const moneyFmt = useMemo(
() =>
new Intl.NumberFormat(currency.locale, {
style: "currency",
currency: currency.code,
maximumFractionDigits: 0,
}),
[currency],
);
const numFmt = useMemo(
() => new Intl.NumberFormat(currency.locale, { maximumFractionDigits: 0 }),
[currency],
);
const money = useCallback((n: number) => moneyFmt.format(Math.round(n)), [moneyFmt]);
const num = useCallback((n: number) => numFmt.format(Math.round(n)), [numFmt]);
const setField = useCallback((key: FieldKey, next: number) => {
setInputs((prev) => ({ ...prev, [key]: next }));
}, []);
/* --------------------------------------------------------- the maths */
const model = useMemo(() => {
const { people, hoursPerWeek, hourlyCost, automationPct, seatCost, setupCost } = inputs;
const manualHoursMonth = people * hoursPerWeek * WEEKS_PER_MONTH;
const hoursReclaimed = manualHoursMonth * (automationPct / 100);
const grossMonthly = hoursReclaimed * hourlyCost;
const toolMonthly = people * seatCost;
const netMonthly = grossMonthly - toolMonthly;
const yearCost = toolMonthly * HORIZON + setupCost;
const yearGross = grossMonthly * HORIZON;
const yearNet = yearGross - yearCost;
const roiPct = yearCost > 0 ? (yearNet / yearCost) * 100 : null;
const paybackMonths = netMonthly > 0 ? setupCost / netMonthly : null;
const series = Array.from({ length: HORIZON }, (_, i) => {
const month = i + 1;
return { month, cum: netMonthly * month - setupCost };
});
const breakEven = series.find((p) => p.cum >= 0)?.month ?? null;
return {
manualHoursMonth,
hoursReclaimed,
grossMonthly,
toolMonthly,
netMonthly,
yearCost,
yearNet,
roiPct,
paybackMonths,
series,
breakEven,
};
}, [inputs]);
const profitable = model.netMonthly > 0;
const roiText =
model.roiPct === null ? "No cost entered" : `${model.roiPct >= 0 ? "+" : ""}${num(model.roiPct)}%`;
const paybackText = useMemo(() => {
if (model.paybackMonths === null) return "Never at these inputs";
if (model.paybackMonths <= 0) return "Immediate";
if (model.paybackMonths < 1) return "Under 1 month";
return `${model.paybackMonths.toFixed(1)} months`;
}, [model.paybackMonths]);
const animatedNet = useCountUp(model.yearNet, !reduce);
/* ------------------------------------------------------ live region */
useEffect(() => {
const id = window.setTimeout(() => {
setLive(
`Year one net: ${money(model.yearNet)}. Return on investment ${roiText}. Payback ${paybackText}.`,
);
}, 600);
return () => window.clearTimeout(id);
}, [model.yearNet, roiText, paybackText, money]);
/* --------------------------------------------------------- handlers */
const onCurrencyKey = useCallback(
(e: ReactKeyboardEvent<HTMLButtonElement>, index: number) => {
let target = -1;
if (e.key === "ArrowRight" || e.key === "ArrowDown") target = (index + 1) % CURRENCIES.length;
else if (e.key === "ArrowLeft" || e.key === "ArrowUp")
target = (index - 1 + CURRENCIES.length) % CURRENCIES.length;
else if (e.key === "Home") target = 0;
else if (e.key === "End") target = CURRENCIES.length - 1;
if (target < 0) return;
const next = CURRENCIES[target];
if (!next) return;
e.preventDefault();
setCurrencyCode(next.code);
currencyRefs.current[target]?.focus();
},
[],
);
const onReset = useCallback(() => {
setInputs(DEFAULTS);
setCurrencyCode("USD");
}, []);
const onCopy = useCallback(() => {
const lines = [
"Automation ROI — year one",
`Team: ${num(inputs.people)} people x ${inputs.hoursPerWeek}h/week at ${money(inputs.hourlyCost)}/h`,
`Automated: ${inputs.automationPct}% of that work`,
`Hours reclaimed: ${num(model.hoursReclaimed)} per month`,
`Gross saving: ${money(model.grossMonthly)} per month`,
`Tool cost: ${money(model.toolMonthly)} per month + ${money(inputs.setupCost)} setup`,
`Net: ${money(model.netMonthly)} per month`,
`Year one net: ${money(model.yearNet)} (ROI ${roiText})`,
`Payback: ${paybackText}`,
];
void navigator.clipboard
.writeText(lines.join("\n"))
.then(() => {
setCopied(true);
if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
copyTimer.current = window.setTimeout(() => setCopied(false), 1800);
})
.catch(() => setCopied(false));
}, [inputs, model, money, num, roiText, paybackText]);
/* ------------------------------------------------------------ chart */
const posMax = Math.max(0, ...model.series.map((p) => p.cum));
const negMax = Math.max(0, ...model.series.map((p) => -p.cum));
const topFlex = posMax > 0 ? posMax : negMax > 0 ? 0.0001 : 1;
const bottomFlex = negMax > 0 ? negMax : 0;
const displayFor = useCallback(
(config: FieldConfig): string => {
const v = inputs[config.key];
if (config.unit === "money") return money(v);
if (config.unit === "pct") return `${v}%`;
if (config.unit === "hours") return `${v} h`;
return num(v);
},
[inputs, money, num],
);
const transition = reduce ? { duration: 0 } : { duration: 0.5, ease: EASE };
return (
<section className="calcroi-root relative w-full bg-slate-50 px-4 py-20 sm:px-6 sm:py-24 dark:bg-slate-950">
<style>{`
.calcroi-root {
--calcroi-track: #e2e8f0;
--calcroi-accent: #4f46e5;
--calcroi-ring: rgba(79, 70, 229, 0.28);
}
@media (prefers-color-scheme: dark) {
.calcroi-root {
--calcroi-track: #334155;
--calcroi-accent: #818cf8;
--calcroi-ring: rgba(129, 140, 248, 0.32);
}
}
.dark .calcroi-root,
[data-theme="dark"] .calcroi-root {
--calcroi-track: #334155;
--calcroi-accent: #818cf8;
--calcroi-ring: rgba(129, 140, 248, 0.32);
}
.calcroi-range {
-webkit-appearance: none;
appearance: none;
background: transparent;
}
.calcroi-range::-webkit-slider-runnable-track {
height: 6px;
border-radius: 999px;
background: linear-gradient(
to right,
var(--calcroi-accent) 0 var(--calcroi-fill),
var(--calcroi-track) var(--calcroi-fill) 100%
);
}
.calcroi-range::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
height: 18px;
width: 18px;
margin-top: -6px;
border-radius: 999px;
background: #ffffff;
border: 2px solid var(--calcroi-accent);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.3);
transition: transform 0.15s ease;
}
.calcroi-range:active::-webkit-slider-thumb {
transform: scale(1.14);
}
.calcroi-range::-moz-range-track {
height: 6px;
border-radius: 999px;
background: var(--calcroi-track);
}
.calcroi-range::-moz-range-progress {
height: 6px;
border-radius: 999px;
background: var(--calcroi-accent);
}
.calcroi-range::-moz-range-thumb {
height: 16px;
width: 16px;
border-radius: 999px;
background: #ffffff;
border: 2px solid var(--calcroi-accent);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.3);
}
.calcroi-range:focus-visible::-webkit-slider-thumb {
box-shadow: 0 0 0 4px var(--calcroi-ring);
}
.calcroi-range:focus-visible::-moz-range-thumb {
box-shadow: 0 0 0 4px var(--calcroi-ring);
}
@keyframes calcroi-sheen {
0% { transform: translateX(-120%); }
100% { transform: translateX(220%); }
}
@keyframes calcroi-blip {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.35; transform: scale(0.72); }
}
.calcroi-sheen {
animation: calcroi-sheen 4.6s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
.calcroi-blip {
animation: calcroi-blip 2s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.calcroi-sheen,
.calcroi-blip {
animation: none !important;
}
.calcroi-range::-webkit-slider-thumb {
transition: none;
}
}
`}</style>
<div className="mx-auto max-w-6xl">
{/* ---------------------------------------------------- header */}
<motion.header
initial={reduce ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={transition}
className="max-w-2xl"
>
<span className="inline-flex items-center gap-1.5 rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-medium text-indigo-700 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300">
<BoltIcon />
Automation ROI
</span>
<h2 className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-slate-50">
What does the manual version actually cost you?
</h2>
<p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-400">
Drag the six numbers below. We work out the hours you get back, the month you break
even, and whether year one clears the licence and setup fees — or quietly doesn’t.
</p>
</motion.header>
<div className="mt-10 grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,26rem)] lg:gap-8">
{/* -------------------------------------------------- inputs */}
<motion.div
initial={reduce ? false : { opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={reduce ? { duration: 0 } : { duration: 0.5, ease: EASE, delay: 0.05 }}
className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-7 dark:border-slate-800 dark:bg-slate-900"
>
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 pb-5 dark:border-slate-800">
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
Your inputs
</h3>
<div className="flex items-center gap-2">
<span
id="calcroi-currency-label"
className="text-xs font-medium text-slate-500 dark:text-slate-400"
>
Currency
</span>
<div
role="radiogroup"
aria-labelledby="calcroi-currency-label"
className="inline-flex rounded-lg border border-slate-200 bg-slate-100 p-0.5 dark:border-slate-700 dark:bg-slate-800"
>
{CURRENCIES.map((c, i) => {
const active = c.code === currencyCode;
return (
<button
key={c.code}
ref={(el) => {
currencyRefs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={active}
aria-label={c.label}
tabIndex={active ? 0 : -1}
onClick={() => setCurrencyCode(c.code)}
onKeyDown={(e) => onCurrencyKey(e, i)}
className={`rounded-[7px] px-2.5 py-1 text-xs font-semibold outline-none transition focus-visible:ring-2 focus-visible:ring-indigo-500/60 dark:focus-visible:ring-indigo-400/60 ${
active
? "bg-white text-indigo-700 shadow-sm dark:bg-slate-950 dark:text-indigo-300"
: "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200"
}`}
>
{c.short}
</button>
);
})}
</div>
</div>
</div>
<div className="divide-y divide-slate-200 dark:divide-slate-800">
{FIELDS.map((config) => (
<SliderField
key={config.key}
config={config}
value={inputs[config.key]}
display={displayFor(config)}
onChange={(next) => setField(config.key, next)}
/>
))}
</div>
<div className="mt-5 flex flex-wrap gap-2 border-t border-slate-200 pt-5 dark:border-slate-800">
<button
type="button"
onClick={onReset}
className="inline-flex items-center gap-1.5 rounded-lg border border-slate-300 bg-white px-3 py-2 text-xs font-semibold text-slate-700 outline-none transition hover:bg-slate-50 focus-visible:ring-2 focus-visible:ring-indigo-500/50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-indigo-400/50"
>
<ResetIcon />
Reset to defaults
</button>
<button
type="button"
onClick={onCopy}
className="inline-flex items-center gap-1.5 rounded-lg border border-slate-300 bg-white px-3 py-2 text-xs font-semibold text-slate-700 outline-none transition hover:bg-slate-50 focus-visible:ring-2 focus-visible:ring-indigo-500/50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-indigo-400/50"
>
{copied ? <CheckIcon /> : <CopyIcon />}
{copied ? "Copied to clipboard" : "Copy the summary"}
</button>
</div>
</motion.div>
{/* ------------------------------------------------- results */}
<motion.div
initial={reduce ? false : { opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={reduce ? { duration: 0 } : { duration: 0.5, ease: EASE, delay: 0.1 }}
className="lg:sticky lg:top-6 lg:self-start"
>
<div className="relative overflow-hidden rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-7 dark:border-slate-800 dark:bg-slate-900">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 overflow-hidden rounded-2xl"
>
<div className="calcroi-sheen absolute -inset-y-8 -left-1/3 w-1/3 rotate-12 bg-gradient-to-r from-transparent via-indigo-500/[0.07] to-transparent dark:via-indigo-300/[0.09]" />
</div>
<div className="relative">
<div className="flex items-center gap-2">
<span
aria-hidden="true"
className={`calcroi-blip h-1.5 w-1.5 rounded-full ${
profitable ? "bg-emerald-500" : "bg-amber-500"
}`}
/>
<h3 className="text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
Net position, year one
</h3>
</div>
<p
className={`mt-2 text-4xl font-semibold tracking-tight tabular-nums sm:text-5xl ${
model.yearNet >= 0
? "text-slate-900 dark:text-slate-50"
: "text-rose-600 dark:text-rose-400"
}`}
>
{money(animatedNet)}
</p>
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
After {money(model.toolMonthly * HORIZON)} of licences and{" "}
{money(inputs.setupCost)} of setup.
</p>
<dl className="mt-6 grid grid-cols-2 gap-3">
<Stat
label="ROI"
value={roiText}
tone={model.roiPct === null ? "neutral" : model.roiPct >= 0 ? "good" : "bad"}
note="Return on year-one spend"
/>
<Stat
label="Payback"
value={paybackText}
tone={profitable ? "good" : "bad"}
note="Time to recoup setup"
/>
<Stat
label="Hours back"
value={`${num(model.hoursReclaimed)} h`}
tone="neutral"
note="Per month, across the team"
/>
<Stat
label="Net per month"
value={money(model.netMonthly)}
tone={profitable ? "good" : "bad"}
note="Savings minus licences"
/>
</dl>
{!profitable ? (
<p className="mt-4 flex gap-2 rounded-xl border border-amber-300 bg-amber-50 p-3 text-xs leading-relaxed text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
<WarnIcon />
<span>
At these numbers the licence costs more than the time it saves. Push the
automated share higher, or negotiate the per-seat price down — the setup fee
is not the problem here.
</span>
</p>
) : null}
<p className="sr-only" role="status" aria-live="polite">
{live}
</p>
</div>
</div>
</motion.div>
</div>
{/* ----------------------------------------------------- chart */}
<motion.figure
initial={reduce ? false : { opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={reduce ? { duration: 0 } : { duration: 0.5, ease: EASE, delay: 0.15 }}
className="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-7 lg:mt-8 dark:border-slate-800 dark:bg-slate-900"
>
<figcaption className="flex flex-wrap items-baseline justify-between gap-2">
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
Cumulative net, month by month
</h3>
<p className="text-xs text-slate-500 dark:text-slate-400">
{model.breakEven === null
? "No break-even inside 12 months"
: `Break-even in month ${model.breakEven}`}
</p>
</figcaption>
<div aria-hidden="true" className="mt-5">
<div className="flex items-stretch gap-1 sm:gap-2">
{model.series.map((p) => {
const isBreak = model.breakEven === p.month;
const upPct = posMax > 0 && p.cum > 0 ? (p.cum / posMax) * 100 : 0;
const downPct = negMax > 0 && p.cum < 0 ? (-p.cum / negMax) * 100 : 0;
return (
<div
key={p.month}
title={`Month ${p.month}: ${money(p.cum)}`}
className="group flex flex-1 flex-col"
>
<div className="flex h-40 flex-col sm:h-48">
<div
className="flex items-end"
style={{ flexGrow: topFlex, flexBasis: 0 }}
>
<motion.div
initial={reduce ? false : { height: "0%" }}
animate={{ height: `${upPct}%` }}
transition={reduce ? { duration: 0 } : { duration: 0.45, ease: EASE }}
className={`w-full rounded-t-[3px] transition-colors ${
isBreak
? "bg-emerald-600 dark:bg-emerald-400"
: "bg-emerald-500/80 group-hover:bg-emerald-500 dark:bg-emerald-500/70 dark:group-hover:bg-emerald-400"
}`}
/>
</div>
<div className="h-px shrink-0 bg-slate-300 dark:bg-slate-700" />
<div
className="flex items-start"
style={{ flexGrow: bottomFlex, flexBasis: 0 }}
>
<motion.div
initial={reduce ? false : { height: "0%" }}
animate={{ height: `${downPct}%` }}
transition={reduce ? { duration: 0 } : { duration: 0.45, ease: EASE }}
className="w-full rounded-b-[3px] bg-rose-500/75 transition-colors group-hover:bg-rose-500 dark:bg-rose-500/60 dark:group-hover:bg-rose-400"
/>
</div>
</div>
<span
className={`mt-2 text-center text-[11px] tabular-nums ${
isBreak
? "font-semibold text-emerald-700 dark:text-emerald-400"
: "text-slate-400 dark:text-slate-500"
}`}
>
{p.month}
</span>
</div>
);
})}
</div>
<div className="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2 text-xs text-slate-500 dark:text-slate-400">
<span className="inline-flex items-center gap-1.5">
<span className="h-2 w-2 rounded-sm bg-emerald-500" />
In the black
</span>
<span className="inline-flex items-center gap-1.5">
<span className="h-2 w-2 rounded-sm bg-rose-500" />
Still paying off setup
</span>
</div>
</div>
<table className="sr-only">
<caption>Cumulative net position by month, first twelve months</caption>
<thead>
<tr>
<th scope="col">Month</th>
<th scope="col">Cumulative net</th>
</tr>
</thead>
<tbody>
{model.series.map((p) => (
<tr key={p.month}>
<th scope="row">Month {p.month}</th>
<td>{money(p.cum)}</td>
</tr>
))}
</tbody>
</table>
</motion.figure>
<p className="mx-auto mt-6 max-w-3xl text-center text-xs leading-relaxed text-slate-500 dark:text-slate-400">
Assumptions: a month is {WEEKS_PER_MONTH.toFixed(2)} weeks (52 ÷ 12). Amounts are read in
the currency you picked — nothing is converted. Reclaimed hours are counted at full loaded
cost, which only holds if that time genuinely moves to billable or revenue work; if it
turns into slack instead, treat the figure as capacity, not cash.
</p>
</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 Loan
Originalloan / mortgage calculator with sliders

Calculator Tip
Originaltip and split 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.

