Skip to content
UI Kit
Accent color

Savings calculator

What visitors save by switching, bundling or paying differently. Tools ticked off against one plan, yearly billing with free months, a tariff over years, a bundle that steps up and commission against a flat fee.

One of 2 components in Calculators

5 variants

Variants

Tool stack

Visitors tick the tools they pay for today. The saving counts to the new figure, two bars weigh today against the one plan. The result stays in view while the list scrolls.

Open in Studio

Yearly billing

Seats on a slider and a gliding switch between monthly and yearly. Twelve months rise in, the free ones light up, the saving follows.

Open in Studio

Tariff over years

Usage on a slider, two tariffs side by side. Bars for each year grow from the ground when they scroll in, the yearly saving counts along.

Open in Studio

Bundle builder

Services as cards to pick. A bar steps up through the discount tiers and names the next one, the bundle price counts to its new figure.

Open in Studio

Commission or flat fee

Bookings, average value and the platform share on sliders. Two bars show what stays of every booking, the yearly saving counts along.

Open in Studio

Details

What Savings calculator brings along, read from the registry and the source.

Category
Calculators
Variants
5
Version
1.0.0 · since 1.1.0
File
components/ui/savings-calculator.tsx
Brings along
utilskitbuttoniconmotionrevealsectionnumber-tickerslider
npm packages
None
Languages
English and German, 32 texts of its own

Props

The SavingsCalculatorProps interface from components/ui/savings-calculator.tsx, with the comments from the code.

  • variantoptional
    SavingsCalculatorVariant

    stackbillingenergybundlecommission

  • eyebrowoptional
    React.ReactNode
  • titlerequired
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • stackoptional
    { items: { name: string; price: number; icon?: IconName; checked?: boolean }[]; plan: { name: string; price: number } }

    stack: what is paid today per month, and the plan that replaces it

  • billingoptional
    { seats: SavingsRange; monthly: number; yearly: number; plan: string }

    billing: seats and the price per seat and month, billed monthly or yearly

  • energyoptional
    { usage: SavingsRange; unit: string; current: { label: string; unitPrice: number; base: number }; offer: { label: string; unitPrice: number; base: number }; years?: number }

    energy: usage per year, unit and two tariffs

  • bundleoptional
    { items: { name: string; price: number; icon?: IconName; text?: string; selected?: boolean }[]; tiers: { count: number; percent: number }[] }

    bundle: services to combine and the discount steps

  • commissionoptional
    { bookings: SavingsRange; value: SavingsRange; rate: SavingsRange; fee: number; platform: string; own: string }

    commission: a platform share against a flat monthly fee

  • currencyoptional
    string

    ISO currency, default EUR

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

Built-in texts

Savings calculator writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 32 texts
todayEnglish You pay todayGerman Du zahlst heute
withEnglish With {n}German Mit {n}
saveEnglish You saveGerman Du sparst
perYearEnglish per yearGerman pro Jahr
perMonthEnglish per monthGerman pro Monat
perMonthShortEnglish / monthGerman / Monat
tickEnglish Tick what you pay for todayGerman Hake ab, wofür du heute zahlst
paysEnglish {x} costs {a} a monthGerman {x} kostet {a} im Monat
monthlyEnglish MonthlyGerman Monatlich
yearlyEnglish YearlyGerman Jährlich
billingEnglish BillingGerman Abrechnung
freeEnglish freeGerman frei
monthsEnglish Your year in monthsGerman Dein Jahr in Monaten
billedMonthlyEnglish Billed monthlyGerman Monatlich bezahlt
billedYearlyEnglish Billed yearlyGerman Jährlich bezahlt
yearCostEnglish per yearGerman pro Jahr
yearsEnglish Over {n} years you keep {a}German In {n} Jahren bleiben dir {a}
yearEnglish Year {n}German Jahr {n}
usageEnglish UsageGerman Verbrauch
baseEnglish Base feeGerman Grundgebühr
perUnitEnglish per {u}German pro {u}
bundleSumEnglish Single pricesGerman Einzelpreise
discountEnglish Bundle discountGerman Paketrabatt
totalEnglish Your bundleGerman Dein Paket
nextEnglish Add {n} more to save {p}German Noch {n} dazu und du sparst {p}
bestEnglish You get the best discountGerman Du bekommst den höchsten Rabatt
chooseEnglish Choose servicesGerman Leistungen wählen
pickEnglish Add {x} to the bundleGerman {x} zum Paket hinzufügen
keepEnglish Of every {a} booking you keepGerman Von jeder Buchung über {a} bleiben dir
platformCostEnglish Platform shareGerman Anteil der Plattform
ownCostEnglish Flat feeGerman Feste Gebühr
monthNamesEnglish Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,DecGerman Jan,Feb,Mär,Apr,Mai,Jun,Jul,Aug,Sep,Okt,Nov,Dez

Accessibility

Read from the source of the component.

  • Savings 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 radio and radiogroup.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • 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).
  • With reduced motion (prefers-reduced-motion) it stays still. 8 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, 658 lines
components/ui/savings-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, type IconName } 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 visitor saves by switching, bundling or paying differently. Each
 * variant compares two prices the visitor can recognize from their own
 * bills, never an abstract score. Amounts are numbers, formatted per locale.
 *
 *   stack       tools paid today, ticked off, against one plan
 *   billing     monthly or yearly with a gliding switch, the free months light up
 *   energy      usage times unit price plus base fee, today against the new tariff, over years
 *   bundle      pick services, the discount steps up, the next step is named
 *   commission  bookings through a platform that takes a share, against a flat fee
 */

export type SavingsCalculatorVariant = 'stack' | 'billing' | 'energy' | 'bundle' | 'commission';

export interface SavingsRange {
  label: string;
  min: number;
  max: number;
  step?: number;
  value: number;
  hint?: string;
}

export interface SavingsCalculatorProps {
  variant?: SavingsCalculatorVariant;
  eyebrow?: React.ReactNode;
  title: React.ReactNode;
  description?: React.ReactNode;
  /** stack: what is paid today per month, and the plan that replaces it */
  stack?: { items: { name: string; price: number; icon?: IconName; checked?: boolean }[]; plan: { name: string; price: number } };
  /** billing: seats and the price per seat and month, billed monthly or yearly */
  billing?: { seats: SavingsRange; monthly: number; yearly: number; plan: string };
  /** energy: usage per year, unit and two tariffs */
  energy?: { usage: SavingsRange; unit: string; current: { label: string; unitPrice: number; base: number }; offer: { label: string; unitPrice: number; base: number }; years?: number };
  /** bundle: services to combine and the discount steps */
  bundle?: { items: { name: string; price: number; icon?: IconName; text?: string; selected?: boolean }[]; tiers: { count: number; percent: number }[] };
  /** commission: a platform share against a flat monthly fee */
  commission?: { bookings: SavingsRange; value: SavingsRange; rate: SavingsRange; fee: number; platform: string; own: string };
  /** ISO currency, default EUR */
  currency?: string;
  cta?: { label: string; href: string };
  note?: string;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    today: 'You pay today',
    with: 'With {n}',
    save: 'You save',
    perYear: 'per year',
    perMonth: 'per month',
    perMonthShort: '/ month',
    tick: 'Tick what you pay for today',
    pays: '{x} costs {a} a month',
    monthly: 'Monthly',
    yearly: 'Yearly',
    billing: 'Billing',
    free: 'free',
    months: 'Your year in months',
    billedMonthly: 'Billed monthly',
    billedYearly: 'Billed yearly',
    yearCost: 'per year',
    years: 'Over {n} years you keep {a}',
    year: 'Year {n}',
    usage: 'Usage',
    base: 'Base fee',
    perUnit: 'per {u}',
    bundleSum: 'Single prices',
    discount: 'Bundle discount',
    total: 'Your bundle',
    next: 'Add {n} more to save {p}',
    best: 'You get the best discount',
    choose: 'Choose services',
    pick: 'Add {x} to the bundle',
    keep: 'Of every {a} booking you keep',
    platformCost: 'Platform share',
    ownCost: 'Flat fee',
    monthNames: 'Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,Dec',
  },
  de: {
    today: 'Du zahlst heute',
    with: 'Mit {n}',
    save: 'Du sparst',
    perYear: 'pro Jahr',
    perMonth: 'pro Monat',
    perMonthShort: '/ Monat',
    tick: 'Hake ab, wofür du heute zahlst',
    pays: '{x} kostet {a} im Monat',
    monthly: 'Monatlich',
    yearly: 'Jährlich',
    billing: 'Abrechnung',
    free: 'frei',
    months: 'Dein Jahr in Monaten',
    billedMonthly: 'Monatlich bezahlt',
    billedYearly: 'Jährlich bezahlt',
    yearCost: 'pro Jahr',
    years: 'In {n} Jahren bleiben dir {a}',
    year: 'Jahr {n}',
    usage: 'Verbrauch',
    base: 'Grundgebühr',
    perUnit: 'pro {u}',
    bundleSum: 'Einzelpreise',
    discount: 'Paketrabatt',
    total: 'Dein Paket',
    next: 'Noch {n} dazu und du sparst {p}',
    best: 'Du bekommst den höchsten Rabatt',
    choose: 'Leistungen wählen',
    pick: '{x} zum Paket hinzufügen',
    keep: 'Von jeder Buchung über {a} bleiben dir',
    platformCost: 'Anteil der Plattform',
    ownCost: 'Feste Gebühr',
    monthNames: 'Jan,Feb,Mär,Apr,Mai,Jun,Jul,Aug,Sep,Okt,Nov,Dez',
  },
};

type Texts = (typeof TEXTS)['en'];
type ViewProps = SavingsCalculatorProps & { t: Texts };

const nf = (l: Locale) => (l === 'de' ? 'de-DE' : 'en-US');
const money = (n: number, l: Locale, currency = 'EUR', digits = 0) => new Intl.NumberFormat(nf(l), { style: 'currency', currency, minimumFractionDigits: digits, maximumFractionDigits: digits }).format(digits ? n : Math.round(n));
const num = (n: number, l: Locale, digits = 0) => new Intl.NumberFormat(nf(l), { maximumFractionDigits: digits }).format(n);
const pctText = (n: number, l: Locale) => `${num(n, l, 1)}${l === 'de' ? ' %' : '%'}`;
const FALLBACK: SavingsRange = { label: '', min: 0, max: 10, value: 1 };

/** A number that counts from its last value to the new one */
function Live({ value, currency, className }: { value: number; currency: string; 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={{ style: 'currency', currency, maximumFractionDigits: 0 }} className={className} />;
}

function useRange(r?: SavingsRange) {
  const [v, setV] = useState(r?.value ?? 0);
  useEffect(() => setV(r?.value ?? 0), [r?.value]);
  return [v, setV] as const;
}

function Head({ eyebrow, title, description, center, className }: Pick<SavingsCalculatorProps, '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>
  );
}

/** The saving, large, in the accent of success */
function Saving({ t, value, currency, monthly, className }: { t: Texts; value: number; currency: string; monthly?: number; className?: string }) {
  const locale = useLocale();
  return (
    <div className={className} aria-live="polite">
      <p className="text-[14px] font-medium text-muted-foreground">
        {t.save} <span className="text-subtle-foreground">{t.perYear}</span>
      </p>
      <p className="mt-2">
        <Live value={Math.max(0, value)} currency={currency} className="text-[clamp(2.5rem,1.9rem+2.6vw,3.75rem)] font-semibold leading-none tracking-[-0.04em] text-success-foreground" />
      </p>
      {monthly !== undefined && (
        <p className="mt-2 text-[13.5px] text-muted-foreground">
          {money(Math.max(0, monthly), locale, currency)} {t.perMonth}
        </p>
      )}
    </div>
  );
}

function Foot({ cta, note, className }: Pick<SavingsCalculatorProps, '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>
  );
}

function Field({ range, value, onChange, format }: { range: SavingsRange; value: number; onChange: (v: number) => void; format: (v: number) => string }) {
  return (
    <div>
      <Slider label={range.label} min={range.min} max={range.max} step={range.step ?? 1} value={value} onValueChange={onChange} format={format} />
      {range.hint && <p className="mt-0.5 text-[12.5px] text-muted-foreground">{range.hint}</p>}
    </div>
  );
}

/** Two horizontal bars that grow when they scroll in */
function CompareBars({ rows, max }: { rows: { label: string; value: string; v: number; tone: 'old' | 'new' }[]; max: number }) {
  const [ref, inView] = useInView<HTMLDivElement>();
  return (
    <div ref={ref} className="grid gap-4" aria-hidden>
      {rows.map((b, i) => (
        <div key={i}>
          <div className="mb-1.5 flex items-baseline justify-between gap-3 text-[13px]">
            <span className="min-w-0 text-muted-foreground">{b.label}</span>
            <span className="shrink-0 font-semibold tabular-nums text-foreground">{b.value}</span>
          </div>
          <div className="h-2.5 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.tone === 'new' ? 'bg-primary' : 'bg-[color-mix(in_oklab,var(--foreground)_32%,var(--card))]')} style={{ transform: `scaleX(${inView ? round(Math.max(0.012, b.v / Math.max(1, max)), 4) : 0})`, transitionDelay: inView ? `${i * 120}ms` : '0ms' }} />
          </div>
        </div>
      ))}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* stack                                                               */
/* ------------------------------------------------------------------ */

function Stack(p: ViewProps) {
  const { t } = p;
  const locale = useLocale();
  const cur = p.currency ?? 'EUR';
  const items = p.stack?.items ?? [];
  const plan = p.stack?.plan ?? { name: '', price: 0 };
  const [on, setOn] = useState<boolean[]>(() => items.map((x) => x.checked !== false));
  useEffect(() => setOn(items.map((x) => x.checked !== false)), [items.length]); // eslint-disable-line react-hooks/exhaustive-deps
  const today = items.reduce((s, x, i) => s + (on[i] ? x.price : 0), 0);
  const monthly = today - plan.price;
  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,25rem)] lg:gap-6">
        <Reveal className="flex flex-col overflow-hidden rounded-[1.5rem] border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]">
          <p className="border-b border-border px-5 py-4 text-[14px] font-medium text-foreground sm:px-6">{t.tick}</p>
          <Stagger as="ul" step={60} className="grid flex-1 auto-rows-fr grid-cols-[minmax(0,1fr)] sm:grid-cols-2">
            {items.map((x, i) => (
              <li key={i} className="border-b border-border sm:odd:border-r [&:nth-last-child(-n+2)]:sm:border-b-0 max-sm:last:border-b-0">
                <label className="group/tool flex h-full min-h-16 cursor-pointer items-center gap-3.5 px-5 py-3.5 transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_3%,transparent)] sm:px-6">
                  <input type="checkbox" className="peer sr-only" checked={!!on[i]} aria-label={t.pays.replace('{x}', x.name).replace('{a}', money(x.price, locale, cur))} onChange={() => setOn((prev) => prev.map((v, k) => (k === i ? !v : v)))} />
                  <span aria-hidden className={cn('grid size-9 shrink-0 place-items-center rounded-lg transition-[background-color,color] duration-300 peer-focus-visible:ring-2 peer-focus-visible:ring-[var(--ring)]', on[i] ? 'bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-primary' : 'bg-muted text-subtle-foreground')}>
                    <Icon name={x.icon ?? 'layers'} size={17} />
                  </span>
                  <span className={cn('min-w-0 flex-1 text-[14.5px] leading-snug transition-colors duration-300', on[i] ? 'text-foreground' : 'text-muted-foreground line-through decoration-[color-mix(in_oklab,var(--foreground)_35%,transparent)]')}>{x.name}</span>
                  <span className={cn('shrink-0 text-[14px] font-semibold tabular-nums transition-colors duration-300', on[i] ? 'text-foreground' : 'text-subtle-foreground')}>{money(x.price, locale, cur)}</span>
                  <span aria-hidden className={cn('grid size-5 shrink-0 place-items-center rounded-md border transition-[background-color,border-color] duration-200', on[i] ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong bg-background')}>
                    <Icon name="check" size={12} strokeWidth={3} className={cn('transition-[opacity,transform] duration-200', on[i] ? 'scale-100 opacity-100' : 'scale-50 opacity-0')} />
                  </span>
                </label>
              </li>
            ))}
          </Stagger>
        </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-7 lg:sticky lg:top-24 lg:self-start">
          <Saving t={t} value={monthly * 12} monthly={monthly} currency={cur} />
          <div className="mt-7">
            <CompareBars
              max={Math.max(today, plan.price)}
              rows={[
                { label: t.today, value: `${money(today, locale, cur)} ${t.perMonthShort}`, v: today, tone: 'old' },
                { label: t.with.replace('{n}', plan.name), value: `${money(plan.price, locale, cur)} ${t.perMonthShort}`, v: plan.price, tone: 'new' },
              ]}
            />
          </div>
          <Foot cta={p.cta} note={p.note} className="mt-8" />
        </Reveal>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* billing                                                             */
/* ------------------------------------------------------------------ */

function Billing(p: ViewProps) {
  const { t } = p;
  const locale = useLocale();
  const cur = p.currency ?? 'EUR';
  const b = p.billing ?? { seats: FALLBACK, monthly: 0, yearly: 0, plan: '' };
  const [seats, setSeats] = useRange(b.seats);
  const [yearly, setYearly] = useState(true);
  const [ref, inView] = useInView<HTMLDivElement>();
  const perYearMonthly = b.monthly * seats * 12;
  const perYearYearly = b.yearly * seats * 12;
  const saving = perYearMonthly - perYearYearly;
  // How many of the twelve months the yearly price leaves free
  const freeMonths = b.monthly > 0 ? Math.max(0, Math.min(12, Math.round(12 - (b.yearly * 12) / b.monthly))) : 0;
  const id = useId();
  return (
    <Section className={p.className}>
      <Head {...p} center />
      <Reveal effect="scale" className="mx-auto mt-12 max-w-[52rem] 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 md:gap-10">
            <Field range={b.seats} value={seats} onChange={setSeats} format={(v) => num(v, locale)} />
            <div>
              <p id={`${id}-b`} className="mb-2 text-sm font-medium text-foreground">
                {t.billing}
              </p>
              <div role="radiogroup" aria-labelledby={`${id}-b`} className="relative grid h-[3.375rem] grid-cols-2 rounded-xl border border-border bg-surface p-1 sm:h-12">
                <span aria-hidden className={cn('absolute inset-y-1 left-1 w-[calc(50%-4px)] rounded-lg bg-card shadow-[var(--highlight),var(--shadow-sm)] transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', yearly && 'translate-x-full')} />
                {[false, true].map((y) => (
                  <button
                    key={String(y)}
                    type="button"
                    role="radio"
                    aria-checked={yearly === y}
                    tabIndex={yearly === y ? 0 : -1}
                    onClick={() => setYearly(y)}
                    onKeyDown={(e) => {
                      if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
                        e.preventDefault();
                        setYearly(!yearly);
                        ((e.currentTarget.parentElement?.querySelectorAll('[role="radio"]')[yearly ? 0 : 1]) as HTMLElement | undefined)?.focus();
                      }
                    }}
                    className={cn('relative z-10 flex items-center justify-center gap-2 rounded-lg text-[14px] font-medium transition-colors duration-300', yearly === y ? 'text-foreground' : 'text-muted-foreground hover:text-foreground')}
                  >
                    {y ? t.yearly : t.monthly}
                    {y && freeMonths > 0 && <span className="rounded-full bg-[color-mix(in_oklab,var(--success)_16%,var(--card))] px-1.5 py-px text-[11.5px] font-semibold text-success-foreground">−{Math.round((1 - b.yearly / b.monthly) * 100)}{locale === 'de' ? ' %' : '%'}</span>}
                  </button>
                ))}
              </div>
            </div>
          </div>
          <div ref={ref} className="mt-8">
            <p className="mb-3 text-[12.5px] font-medium text-muted-foreground">{t.months}</p>
            <ol className="grid grid-cols-6 gap-1.5 sm:grid-cols-12" aria-label={t.months}>
              {Array.from({ length: 12 }, (_, i) => {
                const free = yearly && i >= 12 - freeMonths;
                return (
                  <li
                    key={i}
                    className={cn(
                      'relative flex h-14 flex-col items-center justify-center overflow-hidden rounded-lg border text-[12px] font-medium tabular-nums transition-[background-color,border-color,color,transform,opacity] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none',
                      free ? 'border-[color-mix(in_oklab,var(--success)_45%,var(--border))] bg-[color-mix(in_oklab,var(--success)_12%,var(--card))] text-success-foreground' : 'border-border bg-surface text-muted-foreground',
                      inView ? 'translate-y-0 opacity-100' : 'translate-y-2 opacity-0',
                    )}
                    style={{ transitionDelay: inView ? `${i * 35}ms` : '0ms' }}
                  >
                    <span>{t.monthNames.split(',')[i]}</span>
                    <span className={cn('text-[10.5px] font-semibold uppercase tracking-[0.04em] transition-opacity duration-300', free ? 'opacity-100' : 'opacity-0')}>{t.free}</span>
                  </li>
                );
              })}
            </ol>
          </div>
          <div className="mt-8 grid grid-cols-[minmax(0,1fr)] gap-6 border-t border-border pt-7 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] md:items-end">
            <dl className="grid gap-3 text-[14px]">
              {[
                { label: t.billedMonthly, v: perYearMonthly, on: !yearly },
                { label: t.billedYearly, v: perYearYearly, on: yearly },
              ].map((x) => (
                <div key={x.label} className="flex items-baseline justify-between gap-4">
                  <dt className={cn('transition-colors', x.on ? 'text-foreground' : 'text-muted-foreground')}>{x.label}</dt>
                  <dd className={cn('font-semibold tabular-nums transition-colors', x.on ? 'text-foreground' : 'text-muted-foreground line-through')}>
                    {money(x.v, locale, cur)} <span className="font-normal text-muted-foreground">{t.yearCost}</span>
                  </dd>
                </div>
              ))}
            </dl>
            <div className={cn('transition-opacity duration-300', yearly ? 'opacity-100' : 'opacity-60')}>
              <Saving t={t} value={yearly ? saving : 0} currency={cur} />
            </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 text-[12.5px] text-muted-foreground">{p.note}</p>}
        </div>
      </Reveal>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* energy                                                              */
/* ------------------------------------------------------------------ */

function Energy(p: ViewProps) {
  const { t } = p;
  const locale = useLocale();
  const cur = p.currency ?? 'EUR';
  const e = p.energy ?? { usage: FALLBACK, unit: '', current: { label: '', unitPrice: 0, base: 0 }, offer: { label: '', unitPrice: 0, base: 0 } };
  const [usage, setUsage] = useRange(e.usage);
  const years = e.years ?? 5;
  const costNow = usage * e.current.unitPrice + e.current.base * 12;
  const costNew = usage * e.offer.unitPrice + e.offer.base * 12;
  const saving = costNow - costNew;
  const [ref, inView] = useInView<HTMLDivElement>('0px 0px -10% 0px');
  const max = costNow * years;
  const tariff = (x: { label: string; unitPrice: number; base: number }, fresh: boolean) => (
    <div className={cn('rounded-xl border p-4', fresh ? 'border-[color-mix(in_oklab,var(--primary)_35%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_6%,var(--card))]' : 'border-border bg-card')}>
      <p className={cn('text-[13px] font-medium', fresh ? 'text-foreground' : 'text-muted-foreground')}>{x.label}</p>
      <p className="mt-1.5 text-[19px] font-semibold tabular-nums tracking-[-0.015em] text-foreground">
        {money(x.unitPrice, locale, cur, 2)} <span className="text-[12.5px] font-normal text-muted-foreground">{t.perUnit.replace('{u}', e.unit)}</span>
      </p>
      <p className="mt-0.5 text-[12.5px] text-muted-foreground">
        {t.base} {money(x.base, locale, cur, 2)} {t.perMonth}
      </p>
    </div>
  );
  return (
    <Section className={p.className}>
      <div className="grid grid-cols-[minmax(0,1fr)] gap-12 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:gap-16">
        <div>
          <Head {...p} />
          <div className="mt-8">
            <Field range={e.usage} value={usage} onChange={setUsage} format={(v) => `${num(v, locale)} ${e.unit}`} />
          </div>
          <div className="mt-6 grid grid-cols-2 gap-3">
            {tariff(e.current, false)}
            {tariff(e.offer, true)}
          </div>
        </div>
        <Reveal effect="scale" className="flex flex-col rounded-[1.5rem] border border-border bg-surface p-6 shadow-[var(--highlight),var(--shadow-lg)] sm:p-8">
          <Saving t={t} value={saving} monthly={saving / 12} currency={cur} />
          <div ref={ref} className="mt-8">
            <p className="mb-3 text-[12.5px] font-medium text-muted-foreground">{t.years.replace('{n}', String(years)).replace('{a}', money(saving * years, locale, cur))}</p>
            <div className="flex h-44 items-end gap-2.5 sm:gap-4" aria-hidden>
              {Array.from({ length: years }, (_, i) => {
                const a = (costNow * (i + 1)) / Math.max(1, max);
                const b = (costNew * (i + 1)) / Math.max(1, max);
                return (
                  <div key={i} className="flex h-full min-w-0 flex-1 flex-col justify-end">
                    <div className="relative flex h-full items-end justify-center gap-1">
                      <span className="block h-full w-full max-w-5 origin-bottom rounded-t-md bg-[color-mix(in_oklab,var(--foreground)_28%,var(--card))] transition-transform duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleY(${inView ? round(a, 4) : 0})`, transitionDelay: `${i * 90}ms` }} />
                      <span className="block h-full w-full max-w-5 origin-bottom rounded-t-md bg-primary transition-transform duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleY(${inView ? round(b, 4) : 0})`, transitionDelay: `${i * 90 + 60}ms` }} />
                    </div>
                    <span className="mt-2 text-center text-[11.5px] tabular-nums text-subtle-foreground">{i + 1}</span>
                  </div>
                );
              })}
            </div>
            <p className="mt-4 flex flex-wrap gap-x-5 gap-y-2 text-[12.5px] text-muted-foreground">
              <span className="flex items-center gap-2">
                <span className="size-2.5 rounded-sm bg-[color-mix(in_oklab,var(--foreground)_28%,var(--card))]" />
                {e.current.label}, {money(costNow, locale, cur)} {t.perYear}
              </span>
              <span className="flex items-center gap-2">
                <span className="size-2.5 rounded-sm bg-primary" />
                {e.offer.label}, {money(costNew, locale, cur)} {t.perYear}
              </span>
            </p>
          </div>
          <Foot cta={p.cta} note={p.note} className="mt-8 lg:mt-auto lg:pt-8" />
        </Reveal>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* bundle                                                              */
/* ------------------------------------------------------------------ */

function Bundle(p: ViewProps) {
  const { t } = p;
  const locale = useLocale();
  const cur = p.currency ?? 'EUR';
  const items = p.bundle?.items ?? [];
  const tiers = [...(p.bundle?.tiers ?? [])].sort((a, b) => a.count - b.count);
  const [on, setOn] = useState<boolean[]>(() => items.map((x) => !!x.selected));
  useEffect(() => setOn(items.map((x) => !!x.selected)), [items.length]); // eslint-disable-line react-hooks/exhaustive-deps
  const count = on.filter(Boolean).length;
  const sum = items.reduce((s, x, i) => s + (on[i] ? x.price : 0), 0);
  const tier = [...tiers].reverse().find((x) => count >= x.count);
  const next = tiers.find((x) => count < x.count);
  const pct = tier?.percent ?? 0;
  const off = (sum * pct) / 100;
  const total = sum - off;
  const lastTier = tiers[tiers.length - 1];
  const progress = lastTier ? Math.min(1, count / lastTier.count) : 0;
  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,23rem)] lg:gap-6">
        <fieldset className="min-w-0">
          <legend className="sr-only">{t.choose}</legend>
          <Stagger step={70} className="grid grid-cols-[minmax(0,1fr)] gap-3 sm:grid-cols-2">
            {items.map((x, i) => (
              <label key={i} className="group/item block h-full cursor-pointer">
                <input type="checkbox" className="peer sr-only" checked={!!on[i]} aria-label={t.pick.replace('{x}', x.name)} onChange={() => setOn((prev) => prev.map((v, k) => (k === i ? !v : v)))} />
                <span className={cn('flex h-full flex-col rounded-2xl border bg-card p-5 shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,box-shadow,transform] duration-300 ease-[var(--ease-kit)] hover:-translate-y-0.5 peer-focus-visible:ring-2 peer-focus-visible:ring-[var(--ring)] motion-reduce:hover:translate-y-0', on[i] ? 'border-primary shadow-[0_0_0_3px_var(--glow),var(--shadow-md)]' : 'border-border hover:border-border-strong')}>
                  <span className="flex items-start justify-between gap-3">
                    <span className={cn('grid size-10 place-items-center rounded-xl transition-colors duration-300', on[i] ? 'bg-primary text-primary-foreground' : 'bg-muted text-foreground')}>
                      <Icon name={x.icon ?? 'package'} size={18} />
                    </span>
                    <span aria-hidden className={cn('grid size-6 place-items-center rounded-full border-2 transition-colors duration-300', on[i] ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong')}>
                      <Icon name="check" size={12} strokeWidth={3} className={cn('transition-[opacity,transform] duration-300', on[i] ? 'scale-100 opacity-100' : 'scale-50 opacity-0')} />
                    </span>
                  </span>
                  <span className="mt-4 text-[15.5px] font-semibold text-foreground">{x.name}</span>
                  {x.text && <span className="mt-1 flex-1 text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{x.text}</span>}
                  <span className="mt-4 text-[15px] font-semibold tabular-nums text-foreground">
                    {money(x.price, locale, cur)} <span className="text-[12.5px] font-normal text-muted-foreground">{t.perMonthShort}</span>
                  </span>
                </span>
              </label>
            ))}
          </Stagger>
          <div className="sticky bottom-3 z-10 mt-3 flex items-center justify-between gap-3 rounded-full border border-border bg-[color-mix(in_oklab,var(--card)_90%,transparent)] py-2 pl-5 pr-2 shadow-[var(--highlight),var(--shadow-lg)] backdrop-blur-md lg:hidden" aria-hidden>
            <span className="text-[13.5px] text-muted-foreground">
              <span className="font-semibold tabular-nums text-foreground">{money(total, locale, cur)}</span> {t.perMonthShort}
              {pct > 0 && <span className="ml-2 font-medium text-success-foreground">−{pctText(pct, locale)}</span>}
            </span>
            <span className="rounded-full bg-muted px-3 py-1.5 text-[12.5px] font-medium tabular-nums text-foreground">{count} / {items.length}</span>
          </div>
        </fieldset>
        <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-7 lg:sticky lg:top-24 lg:self-start">
          <div className="relative" aria-hidden>
            <div className="relative h-1.5 overflow-hidden rounded-full bg-track">
              <span className="absolute inset-0 origin-left rounded-full bg-primary transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleX(${round(progress, 3)})` }} />
            </div>
            {tiers.map((x) => (
              <span key={x.count} className={cn('absolute top-1/2 size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 transition-colors duration-300', count >= x.count ? 'border-primary bg-primary' : 'border-border-strong bg-surface')} style={{ left: `${round((x.count / (lastTier?.count ?? 1)) * 100, 3)}%` }} />
            ))}
          </div>
          <div className="relative mt-2.5 h-4 text-[11.5px] tabular-nums text-subtle-foreground" aria-hidden>
            {tiers.map((x, i) => (
              <span key={x.count} className={cn('absolute top-0 whitespace-nowrap transition-colors', i === tiers.length - 1 ? '-translate-x-full' : '-translate-x-1/2', count >= x.count && 'font-semibold text-foreground')} style={{ left: `${round((x.count / (lastTier?.count ?? 1)) * 100, 3)}%` }}>
                −{x.percent}
                {locale === 'de' ? ' %' : '%'}
              </span>
            ))}
          </div>
          <p key={`${count}`} className="kit-rise mt-4 min-h-10 text-pretty text-[13.5px] leading-snug text-foreground" aria-live="polite">
            {next ? t.next.replace('{n}', String(next.count - count)).replace('{p}', pctText(next.percent, locale)) : t.best}
          </p>
          <dl className="mt-4 grid gap-2.5 border-t border-border pt-5 text-[14px]">
            <div className="flex items-baseline justify-between gap-4">
              <dt className="text-muted-foreground">{t.bundleSum}</dt>
              <dd className="tabular-nums text-foreground">{money(sum, locale, cur)}</dd>
            </div>
            <div className="flex items-baseline justify-between gap-4">
              <dt className="text-muted-foreground">
                {t.discount} {pct > 0 && <span className="text-success-foreground">({pctText(pct, locale)})</span>}
              </dt>
              <dd className="tabular-nums text-success-foreground">−{money(off, locale, cur)}</dd>
            </div>
            <div className="mt-1 flex items-baseline justify-between gap-4 border-t border-border pt-3">
              <dt className="font-medium text-foreground">{t.total}</dt>
              <dd className="flex items-baseline gap-1.5">
                <Live value={total} currency={cur} className="text-[28px] font-semibold tracking-[-0.025em] text-foreground" />
                <span className="text-[12.5px] text-muted-foreground">{t.perMonthShort}</span>
              </dd>
            </div>
          </dl>
          <p className="mt-2 text-right text-[12.5px] text-muted-foreground">
            {t.save} {money(off * 12, locale, cur)} {t.perYear}
          </p>
          <Foot cta={p.cta} note={p.note} className="mt-6" />
        </Reveal>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* commission                                                          */
/* ------------------------------------------------------------------ */

function Commission(p: ViewProps) {
  const { t } = p;
  const locale = useLocale();
  const cur = p.currency ?? 'EUR';
  const c = p.commission ?? { bookings: FALLBACK, value: FALLBACK, rate: FALLBACK, fee: 0, platform: '', own: '' };
  const [bookings, setBookings] = useRange(c.bookings);
  const [value, setValue] = useRange(c.value);
  const [rate, setRate] = useRange(c.rate);
  const platformMonth = (bookings * value * rate) / 100;
  const monthly = platformMonth - c.fee;
  const keepPlatform = value * (1 - rate / 100);
  const keepOwn = bookings > 0 ? value - c.fee / bookings : value;
  const [ref, inView] = useInView<HTMLDivElement>();
  const share = (x: number) => round(Math.max(0, Math.min(1, x / Math.max(1, value))), 4);
  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 lg:self-start">
          <Field range={c.bookings} value={bookings} onChange={setBookings} format={(v) => num(v, locale)} />
          <Field range={c.value} value={value} onChange={setValue} format={(v) => money(v, locale, cur)} />
          <Field range={c.rate} value={rate} onChange={setRate} format={(v) => pctText(v, locale)} />
        </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">
          <Saving t={t} value={monthly * 12} monthly={monthly} currency={cur} />
          <div ref={ref} className="mt-8">
            <p className="text-[13px] font-medium text-foreground">{t.keep.replace('{a}', money(value, locale, cur))}</p>
            <div className="mt-4 grid gap-4">
              {[
                { label: c.platform, keep: keepPlatform, cost: value - keepPlatform, costLabel: t.platformCost, tone: 'old' as const },
                { label: c.own, keep: keepOwn, cost: value - keepOwn, costLabel: t.ownCost, tone: 'new' as const },
              ].map((r, i) => (
                <div key={i}>
                  <div className="mb-1.5 flex items-baseline justify-between gap-3 text-[13px]">
                    <span className="min-w-0 text-muted-foreground">{r.label}</span>
                    <span className="shrink-0 font-semibold tabular-nums text-foreground">{money(r.keep, locale, cur, 2)}</span>
                  </div>
                  <div className="relative h-7 overflow-hidden rounded-lg bg-[color-mix(in_oklab,var(--destructive)_16%,var(--card))]" aria-hidden>
                    <span className={cn('absolute inset-0 origin-left rounded-lg transition-transform duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none', r.tone === 'new' ? 'bg-primary' : 'bg-[color-mix(in_oklab,var(--foreground)_32%,var(--card))]')} style={{ transform: `scaleX(${inView ? share(r.keep) : 0})`, transitionDelay: `${i * 120}ms` }} />
                  </div>
                  <p className="mt-1 text-right text-[12px] tabular-nums text-muted-foreground">
                    {r.costLabel} {money(r.cost, locale, cur, 2)}
                  </p>
                </div>
              ))}
            </div>
          </div>
          <Foot cta={p.cta} note={p.note} className="mt-8 lg:mt-auto lg:pt-8" />
        </Reveal>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* SavingsCalculator                                                   */
/* ------------------------------------------------------------------ */

const VARIANTS: Record<SavingsCalculatorVariant, (p: ViewProps) => React.ReactNode> = {
  stack: Stack,
  billing: Billing,
  energy: Energy,
  bundle: Bundle,
  commission: Commission,
};

/**
 * What a visitor saves by switching, bundling or paying differently.
 *   <SavingsCalculator variant="stack" title="…" stack={{ items: [{ name, price, icon }], plan: { name, price } }} cta={{ label, href }} />
 */
export function SavingsCalculator({ variant = 'stack', labels, ...props }: SavingsCalculatorProps) {
  const t = useTexts(TEXTS, labels);
  const View = VARIANTS[variant] ?? Stack;
  return <View {...props} t={t} />;
}

Frequent questions

How do I install Savings calculator?

With aiw add @ai-website/savings-calculator. The command puts components/ui/savings-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 Savings calculator have?

5 variants, Tool stack, Yearly billing, Tariff over years, Bundle builder and Commission or flat fee. You pick one with the variant prop, for example variant="billing".

Can I translate the texts of Savings calculator?

Yes. Savings calculator carries 32 texts of its own in English and German, such as “You pay today”, “You save” and “per year”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Savings calculator?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Savings calculator with aiw_components and install it with aiw_add @ai-website/savings-calculator. Name the variant in your prompt, for example variant="stack", and the assistant writes your content straight into the props.

Can I adjust Savings calculator without code?

Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching SavingsCalculator code to copy.

Often searched

Jump to

Copied to the clipboard