ROI-Rechner
Was ein Produkt zurückbringt, ausgerechnet von den Besuchern selbst. Regler mit Live-Ergebnis, eine Amortisationskurve, Szenarien, eine Frage nach der anderen und ein Umsatztrichter. Die Rechnung bleibt offen und einfach.
Eine von 2 Components in Rechner
Variants
Details
Was ROI-Rechner mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Rechner
- Bereich
- Marketing
- Variants
- 5
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/roi-calculator.tsx
- Bringt mit
- utilskitbuttoniconmotionrevealsectionnumber-tickerslider
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 38 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle RoiCalculatorProps aus components/ui/roi-calculator.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalRoiCalculatorVariantsliderspaybackscenariosstepsrevenue
eyebrowoptionalReact.ReactNodetitlePflichtReact.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
Eingebaute Texte
Diese Texte schreibt ROI-Rechner selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 38 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| value | Englisch Value of the time saved | Deutsch Wert der gesparten Zeit |
| perYear | Englisch per year | Deutsch pro Jahr |
| perMonth | Englisch per month | Deutsch pro Monat |
| net | Englisch Net gain | Deutsch Netto-Gewinn |
| roi | Englisch Return | Deutsch Rendite |
| roiValue | Englisch {x}× | Deutsch {x}× |
| roiNote | Englisch of what you pay | Deutsch von dem, was du zahlst |
| payback | Englisch Pays for itself after | Deutsch Rechnet sich nach |
| days | Englisch {n} days | Deutsch {n} Tagen |
| daysShort | Englisch {n} days | Deutsch {n} Tage |
| paybackShort | Englisch until it pays off | Deutsch bis es sich rechnet |
| hoursBack | Englisch Hours back | Deutsch Stunden zurück |
| cost | Englisch Cost of {n} | Deutsch Kosten für {n} |
| costYear | Englisch Your cost per year | Deutsch Deine Kosten pro Jahr |
| breakeven | Englisch Break-even in month {n} | Deutsch Gewinnschwelle im Monat {n} |
| noBreakeven | Englisch No break-even within 12 months | Deutsch Keine Gewinnschwelle in 12 Monaten |
| month | Englisch Month | Deutsch Monat |
| saved | Englisch Saved | Deutsch Gespart |
| paid | Englisch Paid | Deutsch Bezahlt |
| months | Englisch Months | Deutsch Monate |
| scenario | Englisch Scenario | Deutsch Szenario |
| assumptions | Englisch Assumptions | Deutsch Annahmen |
| next | Englisch Next | Deutsch Weiter |
| back | Englisch Back | Deutsch Zurück |
| restart | Englisch Start over | Deutsch Neu starten |
| see | Englisch Show result | Deutsch Ergebnis zeigen |
| result | Englisch Your result | Deutsch Dein Ergebnis |
| step | Englisch Question {n} of {t} | Deutsch Frage {n} von {t} |
| increase | Englisch More | Deutsch Mehr |
| decrease | Englisch Less | Deutsch Weniger |
| breakdown | Englisch How we got there | Deutsch So haben wir gerechnet |
| extra | Englisch Extra revenue | Deutsch Mehr Umsatz |
| extraOrders | Englisch more orders a month | Deutsch Bestellungen mehr im Monat |
| today | Englisch Today | Deutsch Heute |
| after | Englisch With {n} | Deutsch Mit {n} |
| visitors | Englisch Visitors | Deutsch Besucher |
| ordersMonth | Englisch orders a month | Deutsch Bestellungen im Monat |
| chart | Englisch Saved and paid over 12 months | Deutsch Gespart und bezahlt über 12 Monate |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- ROI-Rechner reagiert selbst auf die Tasten Pfeil links, Pfeil rechts und Leertaste.
- Setzt aria-checked, aria-label, aria-labelledby und aria-live, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen img, radio und radiogroup.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 6 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 728 Zeilen
'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} />;
}Häufige Fragen
Wie installiere ich ROI-Rechner?
Mit aiw add @ai-website/roi-calculator. Der Befehl legt die Datei components/ui/roi-calculator.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, motion, reveal, section, number-ticker und slider. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat ROI-Rechner?
5 Varianten, Regler, Amortisationskurve, Szenarien, Schritt für Schritt und Mehr Umsatz. Du wählst sie mit der Prop variant, zum Beispiel variant="payback".
Kann ich die Texte von ROI-Rechner übersetzen?
Ja. ROI-Rechner bringt 38 eigene Texte auf Deutsch und Englisch mit, etwa „Wert der gesparten Zeit“, „pro Jahr“ und „pro Monat“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent ROI-Rechner einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini ROI-Rechner mit aiw_components und installieren sie mit aiw_add @ai-website/roi-calculator. Nenn im Prompt die Variante, etwa variant="sliders", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich ROI-Rechner ohne Code anpassen?
Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden RoiCalculator-Code zum Kopieren.