ROI calculator
What a product earns back, worked out by the visitor. Sliders with live results, a payback chart, scenarios, one question at a time and a revenue funnel. The model stays open and simple.
One of 2 components in Calculators
Variants
Details
What ROI calculator brings along, read from the registry and the source.
- Category
- Calculators
- Area
- Marketing
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/roi-calculator.tsx
- Brings along
- utilskitbuttoniconmotionrevealsectionnumber-tickerslider
- npm packages
- None
- Languages
- English and German, 38 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The RoiCalculatorProps interface from components/ui/roi-calculator.tsx, with the comments from the code.
variantoptionalRoiCalculatorVariantsliderspaybackscenariosstepsrevenue
eyebrowoptionalReact.ReactNodetitlerequiredReact.ReactNodedescriptionoptionalReact.ReactNodeinputsoptional{ people: RoiRange; hours: RoiRange; rate: RoiRange }sliders, payback, scenarios, steps: the time model
priceoptionalRoiPriceweeksoptionalnumberWorking weeks per year, default 46
scenariosoptional{ label: string; hours: number; note?: string }[]scenarios: hours saved per person and week in each case
assumptionsoptionalstring[]scenarios: the assumptions, shown openly
questionsoptional[string, string, string]steps: one question per input, same order as people, hours, rate
revenueoptional{ visitors: RoiRange; conversion: RoiRange; order: RoiRange; uplift: RoiRange }revenue: the shop or lead model
currencyoptionalstringISO currency, default EUR
hourUnitoptionalstringUnit after hours, e.g. h or Std.
ctaoptional{ label: string; href: string }noteoptionalstringSmall print under the result, e.g. where the averages come from
Built-in texts
ROI calculator writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 38 texts
| Key | English | German |
|---|---|---|
| value | English Value of the time saved | German Wert der gesparten Zeit |
| perYear | English per year | German pro Jahr |
| perMonth | English per month | German pro Monat |
| net | English Net gain | German Netto-Gewinn |
| roi | English Return | German Rendite |
| roiValue | English {x}× | German {x}× |
| roiNote | English of what you pay | German von dem, was du zahlst |
| payback | English Pays for itself after | German Rechnet sich nach |
| days | English {n} days | German {n} Tagen |
| daysShort | English {n} days | German {n} Tage |
| paybackShort | English until it pays off | German bis es sich rechnet |
| hoursBack | English Hours back | German Stunden zurück |
| cost | English Cost of {n} | German Kosten für {n} |
| costYear | English Your cost per year | German Deine Kosten pro Jahr |
| breakeven | English Break-even in month {n} | German Gewinnschwelle im Monat {n} |
| noBreakeven | English No break-even within 12 months | German Keine Gewinnschwelle in 12 Monaten |
| month | English Month | German Monat |
| saved | English Saved | German Gespart |
| paid | English Paid | German Bezahlt |
| months | English Months | German Monate |
| scenario | English Scenario | German Szenario |
| assumptions | English Assumptions | German Annahmen |
| next | English Next | German Weiter |
| back | English Back | German Zurück |
| restart | English Start over | German Neu starten |
| see | English Show result | German Ergebnis zeigen |
| result | English Your result | German Dein Ergebnis |
| step | English Question {n} of {t} | German Frage {n} von {t} |
| increase | English More | German Mehr |
| decrease | English Less | German Weniger |
| breakdown | English How we got there | German So haben wir gerechnet |
| extra | English Extra revenue | German Mehr Umsatz |
| extraOrders | English more orders a month | German Bestellungen mehr im Monat |
| today | English Today | German Heute |
| after | English With {n} | German Mit {n} |
| visitors | English Visitors | German Besucher |
| ordersMonth | English orders a month | German Bestellungen im Monat |
| chart | English Saved and paid over 12 months | German Gespart und bezahlt über 12 Monate |
Accessibility
Read from the source of the component.
- ROI calculator handles the keys Arrow left, Arrow right and Space itself.
- Sets aria-checked, aria-label, aria-labelledby and aria-live, so screen readers announce purpose and state.
- Uses the roles img, radio and radiogroup.
- Announces changes through a live region without moving focus.
- Adds text for screen readers where icons and graphics stand alone.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 6 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 728 lines
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, round, useInView, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Stagger, Magnetic } from '@/components/ui/motion';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { NumberTicker } from '@/components/ui/number-ticker';
import { Reveal } from '@/components/ui/reveal';
import { Eyebrow, Section } from '@/components/ui/section';
import { Slider } from '@/components/ui/slider';
/*
* What a product earns back, calculated by the visitor. All figures are plain
* numbers in the props and formatted in the language of the page. The model
* is deliberately simple and shown openly, so a buyer can repeat it:
*
* value per year = people × hours saved per week × hourly cost × working weeks
* cost per year = price × 12 (× people when the price is per seat) + setup
*
* sliders three sliders, the yearly value, return, payback and a cost bar
* payback two sliders and a chart of 12 months, the break-even point moves
* scenarios careful, expected or bold, plus team size, with the assumptions
* steps one question at a time, then the result with its breakdown
* revenue visitors, conversion, order value and uplift, as a funnel
*/
export type RoiCalculatorVariant = 'sliders' | 'payback' | 'scenarios' | 'steps' | 'revenue';
export interface RoiRange {
label: string;
min: number;
max: number;
step?: number;
value: number;
hint?: string;
}
export interface RoiPrice {
amount: number;
/** seat: per person and month. month: flat per month. */
per: 'seat' | 'month';
/** One-off setup cost, part of the first month */
setup?: number;
/** Product or plan name, e.g. Northwind Team */
name: string;
}
export interface RoiCalculatorProps {
variant?: RoiCalculatorVariant;
eyebrow?: React.ReactNode;
title: React.ReactNode;
description?: React.ReactNode;
/** sliders, payback, scenarios, steps: the time model */
inputs?: { people: RoiRange; hours: RoiRange; rate: RoiRange };
price?: RoiPrice;
/** Working weeks per year, default 46 */
weeks?: number;
/** scenarios: hours saved per person and week in each case */
scenarios?: { label: string; hours: number; note?: string }[];
/** scenarios: the assumptions, shown openly */
assumptions?: string[];
/** steps: one question per input, same order as people, hours, rate */
questions?: [string, string, string];
/** revenue: the shop or lead model */
revenue?: { visitors: RoiRange; conversion: RoiRange; order: RoiRange; uplift: RoiRange };
/** ISO currency, default EUR */
currency?: string;
/** Unit after hours, e.g. h or Std. */
hourUnit?: string;
cta?: { label: string; href: string };
/** Small print under the result, e.g. where the averages come from */
note?: string;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
value: 'Value of the time saved',
perYear: 'per year',
perMonth: 'per month',
net: 'Net gain',
roi: 'Return',
roiValue: '{x}×',
roiNote: 'of what you pay',
payback: 'Pays for itself after',
days: '{n} days',
daysShort: '{n} days',
paybackShort: 'until it pays off',
hoursBack: 'Hours back',
cost: 'Cost of {n}',
costYear: 'Your cost per year',
breakeven: 'Break-even in month {n}',
noBreakeven: 'No break-even within 12 months',
month: 'Month',
saved: 'Saved',
paid: 'Paid',
months: 'Months',
scenario: 'Scenario',
assumptions: 'Assumptions',
next: 'Next',
back: 'Back',
restart: 'Start over',
see: 'Show result',
result: 'Your result',
step: 'Question {n} of {t}',
increase: 'More',
decrease: 'Less',
breakdown: 'How we got there',
extra: 'Extra revenue',
extraOrders: 'more orders a month',
today: 'Today',
after: 'With {n}',
visitors: 'Visitors',
ordersMonth: 'orders a month',
chart: 'Saved and paid over 12 months',
},
de: {
value: 'Wert der gesparten Zeit',
perYear: 'pro Jahr',
perMonth: 'pro Monat',
net: 'Netto-Gewinn',
roi: 'Rendite',
roiValue: '{x}×',
roiNote: 'von dem, was du zahlst',
payback: 'Rechnet sich nach',
days: '{n} Tagen',
daysShort: '{n} Tage',
paybackShort: 'bis es sich rechnet',
hoursBack: 'Stunden zurück',
cost: 'Kosten für {n}',
costYear: 'Deine Kosten pro Jahr',
breakeven: 'Gewinnschwelle im Monat {n}',
noBreakeven: 'Keine Gewinnschwelle in 12 Monaten',
month: 'Monat',
saved: 'Gespart',
paid: 'Bezahlt',
months: 'Monate',
scenario: 'Szenario',
assumptions: 'Annahmen',
next: 'Weiter',
back: 'Zurück',
restart: 'Neu starten',
see: 'Ergebnis zeigen',
result: 'Dein Ergebnis',
step: 'Frage {n} von {t}',
increase: 'Mehr',
decrease: 'Weniger',
breakdown: 'So haben wir gerechnet',
extra: 'Mehr Umsatz',
extraOrders: 'Bestellungen mehr im Monat',
today: 'Heute',
after: 'Mit {n}',
visitors: 'Besucher',
ordersMonth: 'Bestellungen im Monat',
chart: 'Gespart und bezahlt über 12 Monate',
},
};
type Texts = (typeof TEXTS)['en'];
type ViewProps = RoiCalculatorProps & { t: Texts };
const nf = (l: Locale) => (l === 'de' ? 'de-DE' : 'en-US');
const money = (n: number, l: Locale, currency = 'EUR') => new Intl.NumberFormat(nf(l), { style: 'currency', currency, maximumFractionDigits: 0 }).format(Math.round(n));
const num = (n: number, l: Locale, digits = 0) => new Intl.NumberFormat(nf(l), { maximumFractionDigits: digits }).format(n);
const FALLBACK_RANGE: RoiRange = { label: '', min: 0, max: 10, value: 1 };
const FALLBACK_PRICE: RoiPrice = { amount: 0, per: 'month', name: '' };
function model(people: number, hours: number, rate: number, price: RoiPrice, weeks: number) {
const value = people * hours * rate * weeks;
const monthly = price.per === 'seat' ? price.amount * people : price.amount;
const cost = monthly * 12 + (price.setup ?? 0);
const roi = cost > 0 ? value / cost : 0;
const payback = value > 0 ? Math.max(1, Math.ceil((cost / value) * 365)) : 0;
return { value, cost, monthly, net: value - cost, roi, payback, hoursYear: people * hours * weeks };
}
/** A number that counts from its last value to the new one, for live results */
function Live({ value, format, className }: { value: number; format: Intl.NumberFormatOptions; className?: string }) {
const last = useRef<number | null>(null);
const from = last.current ?? 0;
useEffect(() => {
last.current = value;
}, [value]);
return <NumberTicker value={Math.round(value)} from={Math.round(from)} duration={last.current === null ? 1300 : 450} format={format} className={className} />;
}
function Head({ eyebrow, title, description, center, className }: Pick<RoiCalculatorProps, 'eyebrow' | 'title' | 'description'> & { center?: boolean; className?: string }) {
return (
<div className={cn('max-w-[46rem]', center && 'mx-auto max-w-[42rem] text-center', className)}>
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{title}</h2>
{description && (
<p className={cn('kit-rise mt-5 max-w-[36rem] text-pretty text-lead text-muted-foreground', center && 'mx-auto')} style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
{description}
</p>
)}
</div>
);
}
function Cta({ cta, note, className }: Pick<RoiCalculatorProps, 'cta' | 'note'> & { className?: string }) {
if (!cta && !note) return null;
return (
<div className={className}>
{cta && (
<Button href={cta.href} size="lg" icon="arrow-right" className="w-full">
{cta.label}
</Button>
)}
{note && <p className="mt-3 text-pretty text-[12.5px] leading-relaxed text-muted-foreground">{note}</p>}
</div>
);
}
/** One input slider with the format of its kind */
function RangeField({ range, value, onChange, kind, locale, currency, hourUnit }: { range: RoiRange; value: number; onChange: (v: number) => void; kind: 'count' | 'hours' | 'money' | 'percent'; locale: Locale; currency: string; hourUnit?: string }) {
const fmt = (v: number) => (kind === 'money' ? money(v, locale, currency) : kind === 'percent' ? `${num(v, locale, 1)}${locale === 'de' ? ' %' : '%'}` : kind === 'hours' ? `${num(v, locale, 1)}${hourUnit ? ` ${hourUnit}` : ''}` : num(v, locale));
return (
<div>
<Slider label={range.label} min={range.min} max={range.max} step={range.step ?? 1} value={value} onValueChange={onChange} format={fmt} />
{range.hint && <p className="mt-0.5 text-[12.5px] text-muted-foreground">{range.hint}</p>}
</div>
);
}
function useTimeInputs(p: RoiCalculatorProps) {
const inputs = p.inputs ?? { people: FALLBACK_RANGE, hours: FALLBACK_RANGE, rate: FALLBACK_RANGE };
const [people, setPeople] = useState(inputs.people.value);
const [hours, setHours] = useState(inputs.hours.value);
const [rate, setRate] = useState(inputs.rate.value);
useEffect(() => setPeople(inputs.people.value), [inputs.people.value]);
useEffect(() => setHours(inputs.hours.value), [inputs.hours.value]);
useEffect(() => setRate(inputs.rate.value), [inputs.rate.value]);
return { inputs, people, setPeople, hours, setHours, rate, setRate };
}
/* ------------------------------------------------------------------ */
/* sliders */
/* ------------------------------------------------------------------ */
function Sliders(p: ViewProps) {
const { t } = p;
const locale = useLocale();
const cur = p.currency ?? 'EUR';
const price = p.price ?? FALLBACK_PRICE;
const s = useTimeInputs(p);
const m = model(s.people, s.hours, s.rate, price, p.weeks ?? 46);
const [barRef, inView] = useInView<HTMLDivElement>();
const scale = Math.max(m.value, m.cost, 1);
const moneyFmt = { style: 'currency', currency: cur, maximumFractionDigits: 0 } as const;
return (
<Section className={p.className}>
<Head {...p} />
<Reveal effect="scale" className="mt-12 sm:mt-14">
<div className="grid grid-cols-[minmax(0,1fr)] overflow-hidden rounded-[1.5rem] border border-border bg-card shadow-[var(--highlight),var(--shadow-lg)] lg:grid-cols-[minmax(0,1fr)_minmax(0,1.05fr)]">
<div className="grid content-start gap-7 p-6 sm:p-8 lg:p-10">
<RangeField range={s.inputs.people} value={s.people} onChange={s.setPeople} kind="count" locale={locale} currency={cur} />
<RangeField range={s.inputs.hours} value={s.hours} onChange={s.setHours} kind="hours" locale={locale} currency={cur} hourUnit={p.hourUnit} />
<RangeField range={s.inputs.rate} value={s.rate} onChange={s.setRate} kind="money" locale={locale} currency={cur} />
<p className="flex items-center gap-2 rounded-xl bg-surface px-4 py-3 text-[13.5px] text-muted-foreground">
<Icon name="receipt" size={16} className="shrink-0" />
<span>
{t.cost.replace('{n}', price.name)} <strong className="font-semibold text-foreground">{money(m.monthly, locale, cur)}</strong> {t.perMonth}
</span>
</p>
</div>
<div className="relative flex flex-col border-t border-border bg-surface p-6 sm:p-8 lg:border-l lg:border-t-0 lg:p-10" aria-live="polite">
<p className="text-[14px] font-medium text-muted-foreground">
{t.value} <span className="text-subtle-foreground">{t.perYear}</span>
</p>
<p className="mt-2">
<Live value={m.value} format={moneyFmt} className="text-[clamp(2.5rem,1.9rem+2.6vw,4rem)] font-semibold leading-none tracking-[-0.04em] text-foreground" />
</p>
<dl className="mt-8 grid grid-cols-3 gap-px overflow-hidden rounded-xl border border-border bg-border">
{[
{ label: t.roi, value: <span className="tabular-nums">{t.roiValue.replace('{x}', num(m.roi, locale, 1))}</span> },
{ label: t.paybackShort, value: <span className="tabular-nums">{t.daysShort.replace('{n}', num(m.payback, locale))}</span> },
{ label: t.hoursBack, value: <span className="tabular-nums">{num(m.hoursYear, locale)}</span> },
].map((x, i) => (
<div key={i} className="flex flex-col-reverse justify-end gap-1 bg-card px-3 py-3.5 sm:px-4">
<dt className="text-[12px] leading-snug text-muted-foreground">{x.label}</dt>
<dd className="text-[clamp(1rem,0.9rem+0.5vw,1.25rem)] font-semibold tracking-[-0.015em] text-foreground">{x.value}</dd>
</div>
))}
</dl>
<div ref={barRef} className="mt-7 space-y-2.5" aria-hidden>
{[
{ label: t.saved, v: m.value, cls: 'bg-primary' },
{ label: t.costYear, v: m.cost, cls: 'bg-[color-mix(in_oklab,var(--foreground)_30%,var(--card))]' },
].map((b) => (
<div key={b.label} className="grid grid-cols-[8.5rem_minmax(0,1fr)] items-center gap-3 text-[12.5px] leading-tight text-muted-foreground">
<span>{b.label}</span>
<span className="h-2.5 overflow-hidden rounded-full bg-track">
<span className={cn('block h-full w-full origin-left rounded-full transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', b.cls)} style={{ transform: `scaleX(${inView ? round(Math.max(0.01, b.v / scale), 4) : 0})` }} />
</span>
</div>
))}
</div>
<Cta cta={p.cta} note={p.note} className="mt-8 lg:mt-auto lg:pt-8" />
</div>
</div>
</Reveal>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* payback */
/* ------------------------------------------------------------------ */
function Payback(p: ViewProps) {
const { t } = p;
const locale = useLocale();
const cur = p.currency ?? 'EUR';
const price = p.price ?? FALLBACK_PRICE;
const s = useTimeInputs(p);
const m = model(s.people, s.hours, s.rate, price, p.weeks ?? 46);
const [ref, inView] = useInView<HTMLDivElement>('0px 0px -15% 0px');
const W = 640;
const H = 280;
const pad = { l: 8, r: 8, t: 16, b: 28 };
const months = Array.from({ length: 13 }, (_, i) => i);
const saved = months.map((i) => (m.value / 12) * i);
const paid = months.map((i) => (i === 0 ? 0 : m.monthly * i + (price.setup ?? 0)));
const top = Math.max(saved[12], paid[12], 1) * 1.08;
const x = (i: number) => round(pad.l + (i / 12) * (W - pad.l - pad.r));
const y = (v: number) => round(H - pad.b - (v / top) * (H - pad.t - pad.b));
const line = (vals: number[]) => vals.map((v, i) => `${i ? 'L' : 'M'}${x(i)} ${y(v)}`).join(' ');
const area = `${line(saved)} L${x(12)} ${y(0)} L${x(0)} ${y(0)} Z`;
// First month in which the savings pass what was paid
const be = months.find((i) => i > 0 && saved[i] >= paid[i]);
const id = useId().replace(/:/g, '');
return (
<Section className={p.className}>
<div className="grid grid-cols-[minmax(0,1fr)] gap-12 lg:grid-cols-[minmax(0,22rem)_minmax(0,1fr)] lg:gap-16">
<div>
<Head {...p} />
<div className="mt-8 grid gap-6">
<RangeField range={s.inputs.people} value={s.people} onChange={s.setPeople} kind="count" locale={locale} currency={cur} />
<RangeField range={s.inputs.hours} value={s.hours} onChange={s.setHours} kind="hours" locale={locale} currency={cur} hourUnit={p.hourUnit} />
</div>
<Cta cta={p.cta} note={p.note} className="mt-8 hidden max-w-[22rem] lg:block" />
</div>
<Reveal effect="scale" className="min-w-0">
<div ref={ref} className="rounded-[1.5rem] border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-lg)] sm:p-7">
<div className="flex flex-wrap items-start justify-between gap-x-6 gap-y-4">
<div aria-live="polite">
<p className="text-[13.5px] font-medium text-muted-foreground">{t.net}</p>
<p className="mt-1.5">
<Live value={m.net} format={{ style: 'currency', currency: cur, maximumFractionDigits: 0 }} className="text-[clamp(2rem,1.6rem+1.8vw,3rem)] font-semibold leading-none tracking-[-0.035em] text-foreground" />
<span className="ml-2 text-[14px] text-muted-foreground">{t.perYear}</span>
</p>
</div>
<p className={cn('inline-flex items-center gap-2 rounded-full px-3 py-1.5 text-[13px] font-medium', be ? 'bg-[color-mix(in_oklab,var(--success)_14%,var(--card))] text-success-foreground' : 'bg-muted text-muted-foreground')}>
<Icon name={be ? 'flag' : 'clock'} size={14} />
<span key={be ?? 0} className="kit-rise">
{be ? t.breakeven.replace('{n}', String(be)) : t.noBreakeven}
</span>
</p>
</div>
<figure className="mt-6">
<div className="relative">
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="block h-[13.5rem] w-full overflow-visible sm:h-[17.5rem]" role="img" aria-label={t.chart}>
<defs>
<linearGradient id={`${id}-fill`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--primary)" stopOpacity="0.22" />
<stop offset="100%" stopColor="var(--primary)" stopOpacity="0" />
</linearGradient>
</defs>
{[0.25, 0.5, 0.75, 1].map((f) => (
<line key={f} vectorEffect="non-scaling-stroke" x1={pad.l} x2={W - pad.r} y1={round(pad.t + (H - pad.t - pad.b) * (1 - f))} y2={round(pad.t + (H - pad.t - pad.b) * (1 - f))} className="stroke-border" strokeDasharray="2 4" />
))}
<line vectorEffect="non-scaling-stroke" x1={pad.l} x2={W - pad.r} y1={y(0)} y2={y(0)} className="stroke-border-strong" />
<path d={area} fill={`url(#${id}-fill)`} className={cn('transition-opacity delay-300 duration-700', inView ? 'opacity-100' : 'opacity-0')} />
<path d={line(paid)} vectorEffect="non-scaling-stroke" fill="none" strokeWidth="2" strokeDasharray="5 5" className="stroke-[color-mix(in_oklab,var(--foreground)_45%,var(--card))]" />
<path d={line(saved)} vectorEffect="non-scaling-stroke" fill="none" strokeWidth="2.75" strokeLinecap="round" className="stroke-primary transition-[clip-path] duration-[1100ms] ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ clipPath: inView ? 'inset(-8px -8px -8px -8px)' : 'inset(-8px 100% -8px -8px)' }} />
</svg>
{be !== undefined && (
<span aria-hidden className="absolute grid size-5 -translate-x-1/2 -translate-y-1/2 place-items-center transition-[left,top] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ left: `${round((x(be) / W) * 100, 3)}%`, top: `${round((y(saved[be]) / H) * 100, 3)}%` }}>
<span className="absolute inset-0 rounded-full bg-primary opacity-25 motion-safe:animate-kit-pulse" />
<span className="relative size-2.5 rounded-full bg-primary ring-2 ring-card" />
</span>
)}
<div aria-hidden className="absolute inset-x-0 bottom-0 h-5">
{months
.filter((i) => i % 3 === 0)
.map((i) => (
<span key={i} className={cn('absolute bottom-0 text-[12px] tabular-nums text-subtle-foreground', i === 0 ? '' : i === 12 ? '-translate-x-full' : '-translate-x-1/2')} style={{ left: `${round((x(i) / W) * 100, 3)}%` }}>
{i}
</span>
))}
</div>
</div>
<figcaption className="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2 text-[12.5px] text-muted-foreground">
<span className="flex items-center gap-2">
<span className="h-0.5 w-5 rounded-full bg-primary" />
{t.saved}
</span>
<span className="flex items-center gap-2">
<span className="h-0 w-5 border-t-2 border-dashed border-[color-mix(in_oklab,var(--foreground)_45%,var(--card))]" />
{t.paid}
</span>
<span className="ml-auto">{t.months}</span>
</figcaption>
</figure>
</div>
</Reveal>
<Cta cta={p.cta} note={p.note} className="-mt-4 sm:max-w-[22rem] lg:hidden" />
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* scenarios */
/* ------------------------------------------------------------------ */
function Scenarios(p: ViewProps) {
const { t } = p;
const locale = useLocale();
const cur = p.currency ?? 'EUR';
const price = p.price ?? FALLBACK_PRICE;
const s = useTimeInputs(p);
const list = p.scenarios ?? [];
const [pick, setPick] = useState(Math.min(1, Math.max(0, list.length - 1)));
const sc = list[pick];
const m = model(s.people, sc?.hours ?? s.hours, s.rate, price, p.weeks ?? 46);
const id = useId();
const moneyFmt = { style: 'currency', currency: cur, maximumFractionDigits: 0 } as const;
return (
<Section className={p.className}>
<Head {...p} center />
<Reveal effect="scale" className="mx-auto mt-12 max-w-[58rem] sm:mt-14">
<div className="rounded-[1.5rem] border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-lg)] sm:p-8">
<div className="grid grid-cols-[minmax(0,1fr)] gap-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] md:items-end">
<div>
<p id={`${id}-sc`} className="mb-2 text-sm font-medium text-foreground">
{t.scenario}
</p>
<div
role="radiogroup"
aria-labelledby={`${id}-sc`}
onKeyDown={(e) => {
const d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
if (!d || !list.length) return;
e.preventDefault();
const next = (pick + d + list.length) % list.length;
setPick(next);
(e.currentTarget.querySelectorAll('[role="radio"]')[next] as HTMLElement | undefined)?.focus();
}}
className="relative grid h-[3.375rem] rounded-xl border border-border bg-surface p-1 sm:h-12"
style={{ gridTemplateColumns: `repeat(${Math.max(1, list.length)}, minmax(0, 1fr))` }}
>
<span aria-hidden className="absolute inset-y-1 left-1 rounded-lg bg-card shadow-[var(--highlight),var(--shadow-sm)] transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ width: `calc((100% - 8px) / ${Math.max(1, list.length)})`, transform: `translateX(${pick * 100}%)` }} />
{list.map((x, i) => (
<button key={i} type="button" role="radio" aria-checked={pick === i} tabIndex={pick === i ? 0 : -1} onClick={() => setPick(i)} className={cn('relative z-10 truncate rounded-lg px-2 text-[14px] font-medium transition-colors duration-300', pick === i ? 'text-foreground' : 'text-muted-foreground hover:text-foreground')}>
{x.label}
</button>
))}
</div>
</div>
<RangeField range={s.inputs.people} value={s.people} onChange={s.setPeople} kind="count" locale={locale} currency={cur} />
</div>
<Stagger step={80} className="mt-8 grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-border bg-border md:grid-cols-4" aria-live="polite">
{[
{ label: `${t.value} ${t.perYear}`, node: <Live value={m.value} format={moneyFmt} /> },
{ label: `${t.net} ${t.perYear}`, node: <Live value={m.net} format={moneyFmt} /> },
{ label: `${t.roi}, ${t.roiNote}`, node: <span className="tabular-nums">{t.roiValue.replace('{x}', num(m.roi, locale, 1))}</span> },
{ label: t.paybackShort, node: <span className="tabular-nums">{t.daysShort.replace('{n}', num(m.payback, locale))}</span> },
].map((x, i) => (
<div key={i} className="bg-card">
<div className="flex h-full flex-col-reverse justify-end gap-1.5 p-4 sm:p-5">
<p className="text-pretty text-[12.5px] leading-snug text-muted-foreground">{x.label}</p>
<p className={cn('font-semibold tracking-[-0.025em] text-foreground', i < 2 ? 'text-[clamp(1.375rem,1.1rem+1vw,1.875rem)]' : 'text-[clamp(1.25rem,1.05rem+0.8vw,1.625rem)]')}>{x.node}</p>
</div>
</div>
))}
</Stagger>
<div className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 md:grid-cols-[minmax(0,1fr)_auto] md:items-end">
<div>
{sc?.note && (
<p key={pick} className="kit-rise mb-4 flex gap-2 text-pretty text-[14px] leading-relaxed text-foreground">
<Icon name="info" size={16} className="mt-0.5 shrink-0 text-primary" />
{sc.note}
</p>
)}
{p.assumptions && p.assumptions.length > 0 && (
<div>
<p className="text-[12.5px] font-medium text-muted-foreground">{t.assumptions}</p>
<ul className="mt-2 flex flex-wrap gap-2">
{p.assumptions.map((a, i) => (
<li key={i} className="rounded-full border border-border bg-surface px-3 py-1 text-[12.5px] text-muted-foreground">
{a}
</li>
))}
</ul>
</div>
)}
</div>
{p.cta && (
<Magnetic className="max-md:flex max-md:w-full">
<Button href={p.cta.href} size="lg" icon="arrow-right" className="max-md:w-full">
{p.cta.label}
</Button>
</Magnetic>
)}
</div>
{p.note && <p className="mt-5 border-t border-border pt-4 text-[12.5px] text-muted-foreground">{p.note}</p>}
</div>
</Reveal>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* steps */
/* ------------------------------------------------------------------ */
function Steps(p: ViewProps) {
const { t } = p;
const locale = useLocale();
const cur = p.currency ?? 'EUR';
const price = p.price ?? FALLBACK_PRICE;
const s = useTimeInputs(p);
const [step, setStep] = useState(0);
const [moved, setMoved] = useState(false);
const m = model(s.people, s.hours, s.rate, price, p.weeks ?? 46);
const fields = [
{ range: s.inputs.people, value: s.people, set: s.setPeople, kind: 'count' as const },
{ range: s.inputs.hours, value: s.hours, set: s.setHours, kind: 'hours' as const },
{ range: s.inputs.rate, value: s.rate, set: s.setRate, kind: 'money' as const },
];
const total = fields.length;
const done = step >= total;
const f = fields[Math.min(step, total - 1)];
const fmt = (v: number, kind: (typeof fields)[number]['kind']) => (kind === 'money' ? money(v, locale, cur) : kind === 'hours' ? `${num(v, locale, 1)}${p.hourUnit ? ` ${p.hourUnit}` : ''}` : num(v, locale));
const go = (n: number) => {
setMoved(true);
setStep(n);
};
const nudge = (d: number) => {
const st = f.range.step ?? 1;
f.set(Math.min(f.range.max, Math.max(f.range.min, round(f.value + d * st, 2))));
};
const progress = done ? 1 : (step + 1) / (total + 1);
const headingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
if (moved) headingRef.current?.focus();
}, [step, moved]);
return (
<Section className={p.className}>
<Head {...p} center />
<Reveal effect="scale" className="mx-auto mt-12 max-w-[40rem] sm:mt-14">
<div className="overflow-hidden rounded-[1.5rem] border border-border bg-card shadow-[var(--highlight),var(--shadow-lg)]">
<div className="h-1 bg-track" aria-hidden>
<div className="h-full origin-left bg-primary transition-transform duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleX(${round(Math.max(0.04, progress), 3)})` }} />
</div>
<div className="p-6 sm:p-9">
{!done ? (
<div key={step} className={cn(moved && 'kit-rise')}>
<p className="text-[13px] font-medium text-muted-foreground">{t.step.replace('{n}', String(step + 1)).replace('{t}', String(total))}</p>
<h3 ref={headingRef} tabIndex={-1} className="mt-2 text-pretty text-[clamp(1.25rem,1.1rem+0.6vw,1.5rem)] font-semibold leading-snug tracking-[-0.015em] text-foreground focus:outline-none">
{p.questions?.[step] ?? f.range.label}
</h3>
<div className="mt-7 flex items-center justify-between gap-3">
<button type="button" onClick={() => nudge(-1)} disabled={f.value <= f.range.min} aria-label={`${t.decrease}, ${f.range.label}`} className="grid size-12 shrink-0 place-items-center rounded-full border border-border bg-background text-foreground transition-[background-color,transform] hover:bg-accent active:scale-95 disabled:opacity-40">
<Icon name="minus" size={18} />
</button>
<output className="min-w-0 text-center text-[clamp(2.25rem,1.7rem+2.4vw,3.5rem)] font-semibold leading-none tracking-[-0.035em] tabular-nums text-foreground" aria-live="polite">
{fmt(f.value, f.kind)}
</output>
<button type="button" onClick={() => nudge(1)} disabled={f.value >= f.range.max} aria-label={`${t.increase}, ${f.range.label}`} className="grid size-12 shrink-0 place-items-center rounded-full border border-border bg-background text-foreground transition-[background-color,transform] hover:bg-accent active:scale-95 disabled:opacity-40">
<Icon name="plus" size={18} />
</button>
</div>
<div className="mt-6 [&_label]:sr-only [&_output]:sr-only">
<RangeField range={{ ...f.range, label: f.range.label }} value={f.value} onChange={f.set} kind={f.kind} locale={locale} currency={cur} hourUnit={p.hourUnit} />
</div>
<div className="mt-8 flex items-center justify-between gap-3 border-t border-border pt-6">
<Button variant="ghost" iconLeft="arrow-left" onClick={() => go(step - 1)} disabled={step === 0}>
{t.back}
</Button>
<Button icon="arrow-right" onClick={() => go(step + 1)}>
{step === total - 1 ? t.see : t.next}
</Button>
</div>
</div>
) : (
<div key="result" className="kit-rise" aria-live="polite">
<p className="text-[13px] font-medium text-muted-foreground">{t.result}</p>
<h3 ref={headingRef} tabIndex={-1} className="mt-3 focus:outline-none">
<span className="block text-[14px] font-medium text-muted-foreground">
{t.net} {t.perYear}
</span>
<NumberTicker value={Math.round(m.net)} trigger="load" duration={1300} format={{ style: 'currency', currency: cur, maximumFractionDigits: 0 }} className="mt-1 text-[clamp(2.5rem,1.9rem+2.6vw,3.75rem)] font-semibold leading-none tracking-[-0.04em] text-foreground" />
</h3>
<p className="mt-6 text-[12.5px] font-medium text-muted-foreground">{t.breakdown}</p>
<dl className="mt-2 divide-y divide-border rounded-xl border border-border">
{[
[`${num(s.people, locale)} × ${num(s.hours, locale, 1)}${p.hourUnit ? ` ${p.hourUnit}` : ''} × ${num(p.weeks ?? 46, locale)} × ${money(s.rate, locale, cur)}`, money(m.value, locale, cur)],
[t.costYear, `− ${money(m.cost, locale, cur)}`],
[t.payback, t.days.replace('{n}', num(m.payback, locale))],
].map(([a, b], i) => (
<div key={i} className="flex items-baseline justify-between gap-4 px-4 py-3 text-[14px]">
<dt className="min-w-0 text-pretty text-muted-foreground">{a}</dt>
<dd className="shrink-0 font-semibold tabular-nums text-foreground">{b}</dd>
</div>
))}
</dl>
<div className="mt-8 flex flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-between">
<Button variant="ghost" iconLeft="rotate-ccw" onClick={() => go(0)}>
{t.restart}
</Button>
{p.cta && (
<Magnetic className="max-sm:flex max-sm:w-full">
<Button href={p.cta.href} icon="arrow-right" className="max-sm:w-full">
{p.cta.label}
</Button>
</Magnetic>
)}
</div>
</div>
)}
</div>
</div>
{p.note && <p className="mt-4 text-center text-[12.5px] text-muted-foreground">{p.note}</p>}
</Reveal>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* revenue */
/* ------------------------------------------------------------------ */
function Revenue(p: ViewProps) {
const { t } = p;
const locale = useLocale();
const cur = p.currency ?? 'EUR';
const r = p.revenue ?? { visitors: FALLBACK_RANGE, conversion: FALLBACK_RANGE, order: FALLBACK_RANGE, uplift: FALLBACK_RANGE };
const [visitors, setVisitors] = useState(r.visitors.value);
const [conv, setConv] = useState(r.conversion.value);
const [order, setOrder] = useState(r.order.value);
const [uplift, setUplift] = useState(r.uplift.value);
useEffect(() => setVisitors(r.visitors.value), [r.visitors.value]);
useEffect(() => setConv(r.conversion.value), [r.conversion.value]);
useEffect(() => setOrder(r.order.value), [r.order.value]);
useEffect(() => setUplift(r.uplift.value), [r.uplift.value]);
const ordersNow = (visitors * conv) / 100;
const ordersNew = ordersNow * (1 + uplift / 100);
const extraMonth = (ordersNew - ordersNow) * order;
const extraYear = extraMonth * 12;
const [ref, inView] = useInView<HTMLDivElement>();
const scale = Math.max(ordersNew, 1);
const name = p.price?.name ?? '';
return (
<Section className={p.className}>
<Head {...p} />
<div className="mt-12 grid grid-cols-[minmax(0,1fr)] gap-4 sm:mt-14 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:gap-6">
<Reveal className="grid content-start gap-6 rounded-[1.5rem] border border-border bg-card p-6 shadow-[var(--highlight),var(--shadow-md)] sm:p-8">
<RangeField range={r.visitors} value={visitors} onChange={setVisitors} kind="count" locale={locale} currency={cur} />
<RangeField range={r.conversion} value={conv} onChange={setConv} kind="percent" locale={locale} currency={cur} />
<RangeField range={r.order} value={order} onChange={setOrder} kind="money" locale={locale} currency={cur} />
<div className="rounded-xl border border-[color-mix(in_oklab,var(--primary)_30%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_6%,var(--card))] px-4 pb-2 pt-4">
<RangeField range={r.uplift} value={uplift} onChange={setUplift} kind="percent" locale={locale} currency={cur} />
</div>
</Reveal>
<Reveal effect="scale" delay={100} className="flex flex-col rounded-[1.5rem] border border-border bg-surface p-6 shadow-[var(--highlight),var(--shadow-lg)] sm:p-8">
<div aria-live="polite">
<p className="text-[14px] font-medium text-muted-foreground">
{t.extra} <span className="text-subtle-foreground">{t.perYear}</span>
</p>
<p className="mt-2">
<Live value={extraYear} format={{ style: 'currency', currency: cur, maximumFractionDigits: 0 }} className="text-[clamp(2.5rem,1.9rem+2.6vw,4rem)] font-semibold leading-none tracking-[-0.04em] text-foreground" />
</p>
<p className="mt-3 text-[14px] text-muted-foreground">
<span className="font-semibold tabular-nums text-success-foreground">+{num(ordersNew - ordersNow, locale)}</span> {t.extraOrders}, {money(extraMonth, locale, cur)} {t.perMonth}
</p>
</div>
<div ref={ref} className="mt-8 grid gap-4" aria-hidden>
{[
{ label: t.today, v: ordersNow, cls: 'bg-[color-mix(in_oklab,var(--foreground)_30%,var(--card))]' },
{ label: t.after.replace('{n}', name), v: ordersNew, cls: 'bg-primary' },
].map((b, i) => (
<div key={i}>
<div className="mb-1.5 flex items-baseline justify-between gap-3 text-[13px]">
<span className="truncate text-muted-foreground">{b.label}</span>
<span className="shrink-0 font-semibold tabular-nums text-foreground">
{num(b.v, locale)} <span className="font-normal text-muted-foreground">{t.ordersMonth}</span>
</span>
</div>
<div className="h-3 overflow-hidden rounded-full bg-track">
<div className={cn('h-full w-full origin-left rounded-full transition-transform duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none', b.cls)} style={{ transform: `scaleX(${inView ? round(Math.max(0.01, b.v / scale), 4) : 0})`, transitionDelay: inView ? `${i * 120}ms` : '0ms' }} />
</div>
</div>
))}
</div>
<Cta cta={p.cta} note={p.note} className="mt-8 lg:mt-auto lg:pt-8" />
</Reveal>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* RoiCalculator */
/* ------------------------------------------------------------------ */
const VARIANTS: Record<RoiCalculatorVariant, (p: ViewProps) => React.ReactNode> = {
sliders: Sliders,
payback: Payback,
scenarios: Scenarios,
steps: Steps,
revenue: Revenue,
};
/**
* What a product earns back, calculated by the visitor.
* <RoiCalculator variant="sliders" title="…" inputs={{ people, hours, rate }} price={{ amount: 12, per: 'seat', name: 'Northwind' }} cta={{ label, href }} />
*/
export function RoiCalculator({ variant = 'sliders', labels, ...props }: RoiCalculatorProps) {
const t = useTexts(TEXTS, labels);
const View = VARIANTS[variant] ?? Sliders;
return <View {...props} t={t} />;
}Frequent questions
How do I install ROI calculator?
With aiw add @ai-website/roi-calculator. The command puts components/ui/roi-calculator.tsx into your project, the code is yours from then on. It brings along button, icon, motion, reveal, section, number-ticker and slider. It needs no further npm packages.
Which variants does ROI calculator have?
5 variants, Sliders, Payback chart, Scenarios, Step by step and Revenue uplift. You pick one with the variant prop, for example variant="payback".
Can I translate the texts of ROI calculator?
Yes. ROI calculator carries 38 texts of its own in English and German, such as “Value of the time saved”, “per year” and “per month”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with ROI calculator?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find ROI calculator with aiw_components and install it with aiw_add @ai-website/roi-calculator. Name the variant in your prompt, for example variant="sliders", and the assistant writes your content straight into the props.
Can I adjust ROI calculator without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching RoiCalculator code to copy.