Zum Inhalt springen
UI Kit
Accent

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

5 Variants

Variants

Regler

Teamgröße, gesparte Stunden und Stundenkosten als Regler. Der Jahreswert zählt zu jedem neuen Betrag, darunter Rendite, Amortisation und Stunden, zwei Balken wiegen Wert gegen Kosten.

Im Studio öffnen

Amortisationskurve

Ersparnis und Zahlungen über zwölf Monate. Die Linie der Ersparnis zeichnet sich beim Hereinscrollen, die Gewinnschwelle wandert mit den Reglern und pulsiert, wo sie liegt.

Im Studio öffnen

Szenarien

Vorsichtig, erwartet oder mutig, mit gleitender Marke und der Teamgröße als Regler. Die Ergebnisse ändern sich an Ort und Stelle, die Annahmen stehen offen darunter.

Im Studio öffnen

Schritt für Schritt

Eine Frage nach der anderen, mit großen Plus- und Minusknöpfen und einem Regler, ein Balken zeigt den Weg. Das Ergebnis zählt hoch und zeigt, wie es gerechnet wurde.

Im Studio öffnen

Mehr Umsatz

Besucher, Conversion und Warenkorb, dazu die Steigerung in einem hervorgehobenen Feld. Der Mehrumsatz zählt mit, zwei Balken vergleichen die Bestellungen heute mit den neuen.

Im Studio öffnen

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

Props

Die Schnittstelle RoiCalculatorProps aus components/ui/roi-calculator.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    RoiCalculatorVariant

    sliderspaybackscenariosstepsrevenue

  • eyebrowoptional
    React.ReactNode
  • titlePflicht
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • inputsoptional
    { people: RoiRange; hours: RoiRange; rate: RoiRange }

    sliders, payback, scenarios, steps: the time model

  • priceoptional
    RoiPrice
  • weeksoptional
    number

    Working weeks per year, default 46

  • scenariosoptional
    { label: string; hours: number; note?: string }[]

    scenarios: hours saved per person and week in each case

  • assumptionsoptional
    string[]

    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

  • currencyoptional
    string

    ISO currency, default EUR

  • hourUnitoptional
    string

    Unit after hours, e.g. h or Std.

  • ctaoptional
    { label: string; href: string }
  • noteoptional
    string

    Small 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
valueEnglisch Value of the time savedDeutsch Wert der gesparten Zeit
perYearEnglisch per yearDeutsch pro Jahr
perMonthEnglisch per monthDeutsch pro Monat
netEnglisch Net gainDeutsch Netto-Gewinn
roiEnglisch ReturnDeutsch Rendite
roiValueEnglisch {x}×Deutsch {x}×
roiNoteEnglisch of what you payDeutsch von dem, was du zahlst
paybackEnglisch Pays for itself afterDeutsch Rechnet sich nach
daysEnglisch {n} daysDeutsch {n} Tagen
daysShortEnglisch {n} daysDeutsch {n} Tage
paybackShortEnglisch until it pays offDeutsch bis es sich rechnet
hoursBackEnglisch Hours backDeutsch Stunden zurück
costEnglisch Cost of {n}Deutsch Kosten für {n}
costYearEnglisch Your cost per yearDeutsch Deine Kosten pro Jahr
breakevenEnglisch Break-even in month {n}Deutsch Gewinnschwelle im Monat {n}
noBreakevenEnglisch No break-even within 12 monthsDeutsch Keine Gewinnschwelle in 12 Monaten
monthEnglisch MonthDeutsch Monat
savedEnglisch SavedDeutsch Gespart
paidEnglisch PaidDeutsch Bezahlt
monthsEnglisch MonthsDeutsch Monate
scenarioEnglisch ScenarioDeutsch Szenario
assumptionsEnglisch AssumptionsDeutsch Annahmen
nextEnglisch NextDeutsch Weiter
backEnglisch BackDeutsch Zurück
restartEnglisch Start overDeutsch Neu starten
seeEnglisch Show resultDeutsch Ergebnis zeigen
resultEnglisch Your resultDeutsch Dein Ergebnis
stepEnglisch Question {n} of {t}Deutsch Frage {n} von {t}
increaseEnglisch MoreDeutsch Mehr
decreaseEnglisch LessDeutsch Weniger
breakdownEnglisch How we got thereDeutsch So haben wir gerechnet
extraEnglisch Extra revenueDeutsch Mehr Umsatz
extraOrdersEnglisch more orders a monthDeutsch Bestellungen mehr im Monat
todayEnglisch TodayDeutsch Heute
afterEnglisch With {n}Deutsch Mit {n}
visitorsEnglisch VisitorsDeutsch Besucher
ordersMonthEnglisch orders a monthDeutsch Bestellungen im Monat
chartEnglisch Saved and paid over 12 monthsDeutsch 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
components/ui/roi-calculator.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert