Web InnoventixFreeCode

Calculator ROI

Original · free

ROI / savings calculator

byWeb InnoventixReact + Tailwind
calcroicalculators
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/calc-roi.json
calc-roi.tsx
"use client";

import {
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type ChangeEvent,
  type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { motion, useReducedMotion } from "motion/react";

/* ------------------------------------------------------------------ types */

type CurrencyCode = "USD" | "EUR" | "GBP" | "INR";

type Currency = {
  code: CurrencyCode;
  label: string;
  short: string;
  locale: string;
};

type FieldKey =
  | "people"
  | "hoursPerWeek"
  | "hourlyCost"
  | "automationPct"
  | "seatCost"
  | "setupCost";

type Inputs = Record<FieldKey, number>;

type Unit = "count" | "hours" | "money" | "pct";

type FieldConfig = {
  key: FieldKey;
  label: string;
  hint: string;
  min: number;
  max: number;
  step: number;
  unit: Unit;
};

/* ------------------------------------------------------------- constants */

const CURRENCIES: readonly Currency[] = [
  { code: "USD", label: "US dollar", short: "USD", locale: "en-US" },
  { code: "EUR", label: "Euro", short: "EUR", locale: "de-DE" },
  { code: "GBP", label: "Pound sterling", short: "GBP", locale: "en-GB" },
  { code: "INR", label: "Indian rupee", short: "INR", locale: "en-IN" },
];

const DEFAULTS: Inputs = {
  people: 8,
  hoursPerWeek: 4,
  hourlyCost: 55,
  automationPct: 60,
  seatCost: 39,
  setupCost: 18000,
};

const FIELDS: readonly FieldConfig[] = [
  {
    key: "people",
    label: "People doing this work",
    hint: "Everyone who touches the workflow today, not headcount for the whole team.",
    min: 1,
    max: 200,
    step: 1,
    unit: "count",
  },
  {
    key: "hoursPerWeek",
    label: "Hours per person, per week",
    hint: "Time each person loses to the manual steps — chasing, copying, re-checking.",
    min: 0.5,
    max: 40,
    step: 0.5,
    unit: "hours",
  },
  {
    key: "hourlyCost",
    label: "Fully loaded hourly cost",
    hint: "Salary plus tax, benefits and overhead — usually 1.25–1.4x base pay.",
    min: 10,
    max: 400,
    step: 1,
    unit: "money",
  },
  {
    key: "automationPct",
    label: "Share of the work automated",
    hint: "Be honest here. Most rollouts land between 50% and 75%, not 100%.",
    min: 5,
    max: 95,
    step: 5,
    unit: "pct",
  },
  {
    key: "seatCost",
    label: "Tool cost per person, per month",
    hint: "The per-seat licence you would actually pay after any annual discount.",
    min: 0,
    max: 300,
    step: 1,
    unit: "money",
  },
  {
    key: "setupCost",
    label: "One-time setup & migration",
    hint: "Implementation, data migration, training — the cheque you write once.",
    min: 0,
    max: 60000,
    step: 100,
    unit: "money",
  },
];

const WEEKS_PER_MONTH = 52 / 12;
const HORIZON = 12;
const EASE: [number, number, number, number] = [0.22, 1, 0.36, 1];

/* ----------------------------------------------------------------- icons */

function BoltIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-4 w-4">
      <path
        d="M13 2 4.5 13.5H11l-1 8.5 8.5-11.5H12l1-8.5Z"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function CopyIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-4 w-4">
      <rect
        x="9"
        y="9"
        width="11"
        height="11"
        rx="2.5"
        stroke="currentColor"
        strokeWidth="1.6"
      />
      <path
        d="M15 5.5A2.5 2.5 0 0 0 12.5 3h-6A3.5 3.5 0 0 0 3 6.5v6A2.5 2.5 0 0 0 5.5 15"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinecap="round"
      />
    </svg>
  );
}

function CheckIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-4 w-4">
      <path
        d="m4.5 12.5 5 5 10-11"
        stroke="currentColor"
        strokeWidth="1.8"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function ResetIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-4 w-4">
      <path
        d="M4 11a8 8 0 1 1 2.3 5.7"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinecap="round"
      />
      <path
        d="M4 4.5V11h6.5"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function WarnIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="mt-0.5 h-4 w-4 shrink-0">
      <path
        d="M12 3.8 21 19.5H3L12 3.8Z"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinejoin="round"
      />
      <path d="M12 10v4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
      <circle cx="12" cy="17" r="0.9" fill="currentColor" />
    </svg>
  );
}

/* ------------------------------------------------------------------ hooks */

function useCountUp(value: number, animate: boolean): number {
  const [display, setDisplay] = useState<number>(value);
  const fromRef = useRef<number>(value);
  const rafRef = useRef<number | null>(null);

  useEffect(() => {
    if (!animate) {
      fromRef.current = value;
      return;
    }

    const from = fromRef.current;
    const delta = value - from;
    const started = performance.now();
    const duration = 420;

    const tick = (now: number) => {
      const t = Math.min(1, (now - started) / duration);
      const eased = 1 - Math.pow(1 - t, 3);
      const current = from + delta * eased;
      fromRef.current = current;
      setDisplay(current);
      if (t < 1) {
        rafRef.current = requestAnimationFrame(tick);
      } else {
        fromRef.current = value;
      }
    };

    rafRef.current = requestAnimationFrame(tick);
    return () => {
      if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
    };
  }, [value, animate]);

  return animate ? display : value;
}

/* ------------------------------------------------------------- sub-parts */

type SliderFieldProps = {
  config: FieldConfig;
  value: number;
  display: string;
  onChange: (next: number) => void;
};

function SliderField({ config, value, display, onChange }: SliderFieldProps) {
  const { key, label, hint, min, max, step } = config;
  const sliderId = `calcroi-${key}`;
  const boxId = `calcroi-${key}-box`;
  const hintId = `calcroi-${key}-hint`;

  // While the box has focus we keep the literal keystrokes, so half-typed values
  // like "12." survive a re-render. Blur hands control back to the slider.
  const [draft, setDraft] = useState<string | null>(null);
  const shown = draft ?? String(value);

  const clamp = useCallback(
    (n: number) => Math.min(max, Math.max(min, n)),
    [min, max],
  );

  const onSlider = useCallback(
    (e: ChangeEvent<HTMLInputElement>) => onChange(clamp(Number(e.target.value))),
    [onChange, clamp],
  );

  const onBox = useCallback(
    (e: ChangeEvent<HTMLInputElement>) => {
      const next = e.target.value;
      setDraft(next);
      const n = Number(next);
      if (next.trim() !== "" && Number.isFinite(n)) onChange(clamp(n));
    },
    [onChange, clamp],
  );

  const onBoxBlur = useCallback(() => {
    const n = Number(shown);
    const settled = shown.trim() === "" || !Number.isFinite(n) ? min : clamp(n);
    onChange(settled);
    setDraft(null);
  }, [shown, min, clamp, onChange]);

  const pct = ((value - min) / (max - min)) * 100;
  const fill = { ["--calcroi-fill"]: `${pct}%` } as CSSProperties;

  return (
    <div className="py-5">
      <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
        <label
          htmlFor={sliderId}
          className="text-sm font-medium text-slate-800 dark:text-slate-100"
        >
          {label}
        </label>
        <div className="flex items-center gap-2">
          <span
            aria-hidden="true"
            className="text-sm tabular-nums text-slate-500 dark:text-slate-400"
          >
            {display}
          </span>
          <input
            id={boxId}
            type="number"
            inputMode="decimal"
            min={min}
            max={max}
            step={step}
            value={shown}
            onChange={onBox}
            onBlur={onBoxBlur}
            aria-label={`${label} — type an exact value`}
            aria-describedby={hintId}
            className="w-20 rounded-lg border border-slate-300 bg-white px-2 py-1 text-right text-sm tabular-nums text-slate-900 shadow-sm outline-none transition focus-visible:border-indigo-500 focus-visible:ring-2 focus-visible:ring-indigo-500/40 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:focus-visible:border-indigo-400 dark:focus-visible:ring-indigo-400/40"
          />
        </div>
      </div>

      <input
        id={sliderId}
        type="range"
        min={min}
        max={max}
        step={step}
        value={value}
        onChange={onSlider}
        aria-describedby={hintId}
        style={fill}
        className="calcroi-range mt-3 h-5 w-full cursor-pointer rounded-full"
      />

      <p id={hintId} className="mt-2 text-xs leading-relaxed text-slate-500 dark:text-slate-400">
        {hint}
      </p>
    </div>
  );
}

type StatProps = {
  label: string;
  value: string;
  tone: "neutral" | "good" | "bad";
  note: string;
};

function Stat({ label, value, tone, note }: StatProps) {
  const toneClass =
    tone === "good"
      ? "text-emerald-600 dark:text-emerald-400"
      : tone === "bad"
        ? "text-rose-600 dark:text-rose-400"
        : "text-slate-900 dark:text-slate-50";

  return (
    <div className="rounded-xl border border-slate-200 bg-white/70 p-4 dark:border-slate-800 dark:bg-slate-900/60">
      <dt className="text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
        {label}
      </dt>
      <dd className={`mt-1.5 text-xl font-semibold tabular-nums ${toneClass}`}>{value}</dd>
      <p className="mt-1 text-xs text-slate-500 dark:text-slate-400">{note}</p>
    </div>
  );
}

/* ------------------------------------------------------------- component */

export default function CalcRoi() {
  const prefersReduced = useReducedMotion();
  const reduce = prefersReduced ?? false;

  const [inputs, setInputs] = useState<Inputs>(DEFAULTS);
  const [currencyCode, setCurrencyCode] = useState<CurrencyCode>("USD");
  const [copied, setCopied] = useState<boolean>(false);
  const [live, setLive] = useState<string>("");

  const copyTimer = useRef<number | null>(null);
  const currencyRefs = useRef<Array<HTMLButtonElement | null>>([]);

  useEffect(
    () => () => {
      if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
    },
    [],
  );

  const currency = useMemo<Currency>(
    () => CURRENCIES.find((c) => c.code === currencyCode) ?? CURRENCIES[0]!,
    [currencyCode],
  );

  const moneyFmt = useMemo(
    () =>
      new Intl.NumberFormat(currency.locale, {
        style: "currency",
        currency: currency.code,
        maximumFractionDigits: 0,
      }),
    [currency],
  );

  const numFmt = useMemo(
    () => new Intl.NumberFormat(currency.locale, { maximumFractionDigits: 0 }),
    [currency],
  );

  const money = useCallback((n: number) => moneyFmt.format(Math.round(n)), [moneyFmt]);
  const num = useCallback((n: number) => numFmt.format(Math.round(n)), [numFmt]);

  const setField = useCallback((key: FieldKey, next: number) => {
    setInputs((prev) => ({ ...prev, [key]: next }));
  }, []);

  /* --------------------------------------------------------- the maths */

  const model = useMemo(() => {
    const { people, hoursPerWeek, hourlyCost, automationPct, seatCost, setupCost } = inputs;

    const manualHoursMonth = people * hoursPerWeek * WEEKS_PER_MONTH;
    const hoursReclaimed = manualHoursMonth * (automationPct / 100);
    const grossMonthly = hoursReclaimed * hourlyCost;
    const toolMonthly = people * seatCost;
    const netMonthly = grossMonthly - toolMonthly;

    const yearCost = toolMonthly * HORIZON + setupCost;
    const yearGross = grossMonthly * HORIZON;
    const yearNet = yearGross - yearCost;

    const roiPct = yearCost > 0 ? (yearNet / yearCost) * 100 : null;
    const paybackMonths = netMonthly > 0 ? setupCost / netMonthly : null;

    const series = Array.from({ length: HORIZON }, (_, i) => {
      const month = i + 1;
      return { month, cum: netMonthly * month - setupCost };
    });

    const breakEven = series.find((p) => p.cum >= 0)?.month ?? null;

    return {
      manualHoursMonth,
      hoursReclaimed,
      grossMonthly,
      toolMonthly,
      netMonthly,
      yearCost,
      yearNet,
      roiPct,
      paybackMonths,
      series,
      breakEven,
    };
  }, [inputs]);

  const profitable = model.netMonthly > 0;

  const roiText =
    model.roiPct === null ? "No cost entered" : `${model.roiPct >= 0 ? "+" : ""}${num(model.roiPct)}%`;

  const paybackText = useMemo(() => {
    if (model.paybackMonths === null) return "Never at these inputs";
    if (model.paybackMonths <= 0) return "Immediate";
    if (model.paybackMonths < 1) return "Under 1 month";
    return `${model.paybackMonths.toFixed(1)} months`;
  }, [model.paybackMonths]);

  const animatedNet = useCountUp(model.yearNet, !reduce);

  /* ------------------------------------------------------ live region */

  useEffect(() => {
    const id = window.setTimeout(() => {
      setLive(
        `Year one net: ${money(model.yearNet)}. Return on investment ${roiText}. Payback ${paybackText}.`,
      );
    }, 600);
    return () => window.clearTimeout(id);
  }, [model.yearNet, roiText, paybackText, money]);

  /* --------------------------------------------------------- handlers */

  const onCurrencyKey = useCallback(
    (e: ReactKeyboardEvent<HTMLButtonElement>, index: number) => {
      let target = -1;
      if (e.key === "ArrowRight" || e.key === "ArrowDown") target = (index + 1) % CURRENCIES.length;
      else if (e.key === "ArrowLeft" || e.key === "ArrowUp")
        target = (index - 1 + CURRENCIES.length) % CURRENCIES.length;
      else if (e.key === "Home") target = 0;
      else if (e.key === "End") target = CURRENCIES.length - 1;
      if (target < 0) return;

      const next = CURRENCIES[target];
      if (!next) return;

      e.preventDefault();
      setCurrencyCode(next.code);
      currencyRefs.current[target]?.focus();
    },
    [],
  );

  const onReset = useCallback(() => {
    setInputs(DEFAULTS);
    setCurrencyCode("USD");
  }, []);

  const onCopy = useCallback(() => {
    const lines = [
      "Automation ROI — year one",
      `Team: ${num(inputs.people)} people x ${inputs.hoursPerWeek}h/week at ${money(inputs.hourlyCost)}/h`,
      `Automated: ${inputs.automationPct}% of that work`,
      `Hours reclaimed: ${num(model.hoursReclaimed)} per month`,
      `Gross saving: ${money(model.grossMonthly)} per month`,
      `Tool cost: ${money(model.toolMonthly)} per month + ${money(inputs.setupCost)} setup`,
      `Net: ${money(model.netMonthly)} per month`,
      `Year one net: ${money(model.yearNet)} (ROI ${roiText})`,
      `Payback: ${paybackText}`,
    ];

    void navigator.clipboard
      .writeText(lines.join("\n"))
      .then(() => {
        setCopied(true);
        if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
        copyTimer.current = window.setTimeout(() => setCopied(false), 1800);
      })
      .catch(() => setCopied(false));
  }, [inputs, model, money, num, roiText, paybackText]);

  /* ------------------------------------------------------------ chart */

  const posMax = Math.max(0, ...model.series.map((p) => p.cum));
  const negMax = Math.max(0, ...model.series.map((p) => -p.cum));
  const topFlex = posMax > 0 ? posMax : negMax > 0 ? 0.0001 : 1;
  const bottomFlex = negMax > 0 ? negMax : 0;

  const displayFor = useCallback(
    (config: FieldConfig): string => {
      const v = inputs[config.key];
      if (config.unit === "money") return money(v);
      if (config.unit === "pct") return `${v}%`;
      if (config.unit === "hours") return `${v} h`;
      return num(v);
    },
    [inputs, money, num],
  );

  const transition = reduce ? { duration: 0 } : { duration: 0.5, ease: EASE };

  return (
    <section className="calcroi-root relative w-full bg-slate-50 px-4 py-20 sm:px-6 sm:py-24 dark:bg-slate-950">
      <style>{`
        .calcroi-root {
          --calcroi-track: #e2e8f0;
          --calcroi-accent: #4f46e5;
          --calcroi-ring: rgba(79, 70, 229, 0.28);
        }
        @media (prefers-color-scheme: dark) {
          .calcroi-root {
            --calcroi-track: #334155;
            --calcroi-accent: #818cf8;
            --calcroi-ring: rgba(129, 140, 248, 0.32);
          }
        }
        .dark .calcroi-root,
        [data-theme="dark"] .calcroi-root {
          --calcroi-track: #334155;
          --calcroi-accent: #818cf8;
          --calcroi-ring: rgba(129, 140, 248, 0.32);
        }

        .calcroi-range {
          -webkit-appearance: none;
          appearance: none;
          background: transparent;
        }
        .calcroi-range::-webkit-slider-runnable-track {
          height: 6px;
          border-radius: 999px;
          background: linear-gradient(
            to right,
            var(--calcroi-accent) 0 var(--calcroi-fill),
            var(--calcroi-track) var(--calcroi-fill) 100%
          );
        }
        .calcroi-range::-webkit-slider-thumb {
          -webkit-appearance: none;
          appearance: none;
          height: 18px;
          width: 18px;
          margin-top: -6px;
          border-radius: 999px;
          background: #ffffff;
          border: 2px solid var(--calcroi-accent);
          box-shadow: 0 1px 3px rgba(15, 23, 42, 0.3);
          transition: transform 0.15s ease;
        }
        .calcroi-range:active::-webkit-slider-thumb {
          transform: scale(1.14);
        }
        .calcroi-range::-moz-range-track {
          height: 6px;
          border-radius: 999px;
          background: var(--calcroi-track);
        }
        .calcroi-range::-moz-range-progress {
          height: 6px;
          border-radius: 999px;
          background: var(--calcroi-accent);
        }
        .calcroi-range::-moz-range-thumb {
          height: 16px;
          width: 16px;
          border-radius: 999px;
          background: #ffffff;
          border: 2px solid var(--calcroi-accent);
          box-shadow: 0 1px 3px rgba(15, 23, 42, 0.3);
        }
        .calcroi-range:focus-visible::-webkit-slider-thumb {
          box-shadow: 0 0 0 4px var(--calcroi-ring);
        }
        .calcroi-range:focus-visible::-moz-range-thumb {
          box-shadow: 0 0 0 4px var(--calcroi-ring);
        }

        @keyframes calcroi-sheen {
          0% { transform: translateX(-120%); }
          100% { transform: translateX(220%); }
        }
        @keyframes calcroi-blip {
          0%, 100% { opacity: 1; transform: scale(1); }
          50% { opacity: 0.35; transform: scale(0.72); }
        }
        .calcroi-sheen {
          animation: calcroi-sheen 4.6s cubic-bezier(0.4, 0, 0.2, 1) infinite;
        }
        .calcroi-blip {
          animation: calcroi-blip 2s ease-in-out infinite;
        }

        @media (prefers-reduced-motion: reduce) {
          .calcroi-sheen,
          .calcroi-blip {
            animation: none !important;
          }
          .calcroi-range::-webkit-slider-thumb {
            transition: none;
          }
        }
      `}</style>

      <div className="mx-auto max-w-6xl">
        {/* ---------------------------------------------------- header */}
        <motion.header
          initial={reduce ? false : { opacity: 0, y: 10 }}
          animate={{ opacity: 1, y: 0 }}
          transition={transition}
          className="max-w-2xl"
        >
          <span className="inline-flex items-center gap-1.5 rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-medium text-indigo-700 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300">
            <BoltIcon />
            Automation ROI
          </span>
          <h2 className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-slate-50">
            What does the manual version actually cost you?
          </h2>
          <p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-400">
            Drag the six numbers below. We work out the hours you get back, the month you break
            even, and whether year one clears the licence and setup fees — or quietly doesn&rsquo;t.
          </p>
        </motion.header>

        <div className="mt-10 grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,26rem)] lg:gap-8">
          {/* -------------------------------------------------- inputs */}
          <motion.div
            initial={reduce ? false : { opacity: 0, y: 12 }}
            animate={{ opacity: 1, y: 0 }}
            transition={reduce ? { duration: 0 } : { duration: 0.5, ease: EASE, delay: 0.05 }}
            className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-7 dark:border-slate-800 dark:bg-slate-900"
          >
            <div className="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 pb-5 dark:border-slate-800">
              <h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
                Your inputs
              </h3>

              <div className="flex items-center gap-2">
                <span
                  id="calcroi-currency-label"
                  className="text-xs font-medium text-slate-500 dark:text-slate-400"
                >
                  Currency
                </span>
                <div
                  role="radiogroup"
                  aria-labelledby="calcroi-currency-label"
                  className="inline-flex rounded-lg border border-slate-200 bg-slate-100 p-0.5 dark:border-slate-700 dark:bg-slate-800"
                >
                  {CURRENCIES.map((c, i) => {
                    const active = c.code === currencyCode;
                    return (
                      <button
                        key={c.code}
                        ref={(el) => {
                          currencyRefs.current[i] = el;
                        }}
                        type="button"
                        role="radio"
                        aria-checked={active}
                        aria-label={c.label}
                        tabIndex={active ? 0 : -1}
                        onClick={() => setCurrencyCode(c.code)}
                        onKeyDown={(e) => onCurrencyKey(e, i)}
                        className={`rounded-[7px] px-2.5 py-1 text-xs font-semibold outline-none transition focus-visible:ring-2 focus-visible:ring-indigo-500/60 dark:focus-visible:ring-indigo-400/60 ${
                          active
                            ? "bg-white text-indigo-700 shadow-sm dark:bg-slate-950 dark:text-indigo-300"
                            : "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200"
                        }`}
                      >
                        {c.short}
                      </button>
                    );
                  })}
                </div>
              </div>
            </div>

            <div className="divide-y divide-slate-200 dark:divide-slate-800">
              {FIELDS.map((config) => (
                <SliderField
                  key={config.key}
                  config={config}
                  value={inputs[config.key]}
                  display={displayFor(config)}
                  onChange={(next) => setField(config.key, next)}
                />
              ))}
            </div>

            <div className="mt-5 flex flex-wrap gap-2 border-t border-slate-200 pt-5 dark:border-slate-800">
              <button
                type="button"
                onClick={onReset}
                className="inline-flex items-center gap-1.5 rounded-lg border border-slate-300 bg-white px-3 py-2 text-xs font-semibold text-slate-700 outline-none transition hover:bg-slate-50 focus-visible:ring-2 focus-visible:ring-indigo-500/50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-indigo-400/50"
              >
                <ResetIcon />
                Reset to defaults
              </button>
              <button
                type="button"
                onClick={onCopy}
                className="inline-flex items-center gap-1.5 rounded-lg border border-slate-300 bg-white px-3 py-2 text-xs font-semibold text-slate-700 outline-none transition hover:bg-slate-50 focus-visible:ring-2 focus-visible:ring-indigo-500/50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-indigo-400/50"
              >
                {copied ? <CheckIcon /> : <CopyIcon />}
                {copied ? "Copied to clipboard" : "Copy the summary"}
              </button>
            </div>
          </motion.div>

          {/* ------------------------------------------------- results */}
          <motion.div
            initial={reduce ? false : { opacity: 0, y: 12 }}
            animate={{ opacity: 1, y: 0 }}
            transition={reduce ? { duration: 0 } : { duration: 0.5, ease: EASE, delay: 0.1 }}
            className="lg:sticky lg:top-6 lg:self-start"
          >
            <div className="relative overflow-hidden rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-7 dark:border-slate-800 dark:bg-slate-900">
              <div
                aria-hidden="true"
                className="pointer-events-none absolute inset-0 overflow-hidden rounded-2xl"
              >
                <div className="calcroi-sheen absolute -inset-y-8 -left-1/3 w-1/3 rotate-12 bg-gradient-to-r from-transparent via-indigo-500/[0.07] to-transparent dark:via-indigo-300/[0.09]" />
              </div>

              <div className="relative">
                <div className="flex items-center gap-2">
                  <span
                    aria-hidden="true"
                    className={`calcroi-blip h-1.5 w-1.5 rounded-full ${
                      profitable ? "bg-emerald-500" : "bg-amber-500"
                    }`}
                  />
                  <h3 className="text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
                    Net position, year one
                  </h3>
                </div>

                <p
                  className={`mt-2 text-4xl font-semibold tracking-tight tabular-nums sm:text-5xl ${
                    model.yearNet >= 0
                      ? "text-slate-900 dark:text-slate-50"
                      : "text-rose-600 dark:text-rose-400"
                  }`}
                >
                  {money(animatedNet)}
                </p>
                <p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
                  After {money(model.toolMonthly * HORIZON)} of licences and{" "}
                  {money(inputs.setupCost)} of setup.
                </p>

                <dl className="mt-6 grid grid-cols-2 gap-3">
                  <Stat
                    label="ROI"
                    value={roiText}
                    tone={model.roiPct === null ? "neutral" : model.roiPct >= 0 ? "good" : "bad"}
                    note="Return on year-one spend"
                  />
                  <Stat
                    label="Payback"
                    value={paybackText}
                    tone={profitable ? "good" : "bad"}
                    note="Time to recoup setup"
                  />
                  <Stat
                    label="Hours back"
                    value={`${num(model.hoursReclaimed)} h`}
                    tone="neutral"
                    note="Per month, across the team"
                  />
                  <Stat
                    label="Net per month"
                    value={money(model.netMonthly)}
                    tone={profitable ? "good" : "bad"}
                    note="Savings minus licences"
                  />
                </dl>

                {!profitable ? (
                  <p className="mt-4 flex gap-2 rounded-xl border border-amber-300 bg-amber-50 p-3 text-xs leading-relaxed text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
                    <WarnIcon />
                    <span>
                      At these numbers the licence costs more than the time it saves. Push the
                      automated share higher, or negotiate the per-seat price down — the setup fee
                      is not the problem here.
                    </span>
                  </p>
                ) : null}

                <p className="sr-only" role="status" aria-live="polite">
                  {live}
                </p>
              </div>
            </div>
          </motion.div>
        </div>

        {/* ----------------------------------------------------- chart */}
        <motion.figure
          initial={reduce ? false : { opacity: 0, y: 12 }}
          animate={{ opacity: 1, y: 0 }}
          transition={reduce ? { duration: 0 } : { duration: 0.5, ease: EASE, delay: 0.15 }}
          className="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-7 lg:mt-8 dark:border-slate-800 dark:bg-slate-900"
        >
          <figcaption className="flex flex-wrap items-baseline justify-between gap-2">
            <h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
              Cumulative net, month by month
            </h3>
            <p className="text-xs text-slate-500 dark:text-slate-400">
              {model.breakEven === null
                ? "No break-even inside 12 months"
                : `Break-even in month ${model.breakEven}`}
            </p>
          </figcaption>

          <div aria-hidden="true" className="mt-5">
            <div className="flex items-stretch gap-1 sm:gap-2">
              {model.series.map((p) => {
                const isBreak = model.breakEven === p.month;
                const upPct = posMax > 0 && p.cum > 0 ? (p.cum / posMax) * 100 : 0;
                const downPct = negMax > 0 && p.cum < 0 ? (-p.cum / negMax) * 100 : 0;

                return (
                  <div
                    key={p.month}
                    title={`Month ${p.month}: ${money(p.cum)}`}
                    className="group flex flex-1 flex-col"
                  >
                    <div className="flex h-40 flex-col sm:h-48">
                      <div
                        className="flex items-end"
                        style={{ flexGrow: topFlex, flexBasis: 0 }}
                      >
                        <motion.div
                          initial={reduce ? false : { height: "0%" }}
                          animate={{ height: `${upPct}%` }}
                          transition={reduce ? { duration: 0 } : { duration: 0.45, ease: EASE }}
                          className={`w-full rounded-t-[3px] transition-colors ${
                            isBreak
                              ? "bg-emerald-600 dark:bg-emerald-400"
                              : "bg-emerald-500/80 group-hover:bg-emerald-500 dark:bg-emerald-500/70 dark:group-hover:bg-emerald-400"
                          }`}
                        />
                      </div>

                      <div className="h-px shrink-0 bg-slate-300 dark:bg-slate-700" />

                      <div
                        className="flex items-start"
                        style={{ flexGrow: bottomFlex, flexBasis: 0 }}
                      >
                        <motion.div
                          initial={reduce ? false : { height: "0%" }}
                          animate={{ height: `${downPct}%` }}
                          transition={reduce ? { duration: 0 } : { duration: 0.45, ease: EASE }}
                          className="w-full rounded-b-[3px] bg-rose-500/75 transition-colors group-hover:bg-rose-500 dark:bg-rose-500/60 dark:group-hover:bg-rose-400"
                        />
                      </div>
                    </div>

                    <span
                      className={`mt-2 text-center text-[11px] tabular-nums ${
                        isBreak
                          ? "font-semibold text-emerald-700 dark:text-emerald-400"
                          : "text-slate-400 dark:text-slate-500"
                      }`}
                    >
                      {p.month}
                    </span>
                  </div>
                );
              })}
            </div>

            <div className="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2 text-xs text-slate-500 dark:text-slate-400">
              <span className="inline-flex items-center gap-1.5">
                <span className="h-2 w-2 rounded-sm bg-emerald-500" />
                In the black
              </span>
              <span className="inline-flex items-center gap-1.5">
                <span className="h-2 w-2 rounded-sm bg-rose-500" />
                Still paying off setup
              </span>
            </div>
          </div>

          <table className="sr-only">
            <caption>Cumulative net position by month, first twelve months</caption>
            <thead>
              <tr>
                <th scope="col">Month</th>
                <th scope="col">Cumulative net</th>
              </tr>
            </thead>
            <tbody>
              {model.series.map((p) => (
                <tr key={p.month}>
                  <th scope="row">Month {p.month}</th>
                  <td>{money(p.cum)}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </motion.figure>

        <p className="mx-auto mt-6 max-w-3xl text-center text-xs leading-relaxed text-slate-500 dark:text-slate-400">
          Assumptions: a month is {WEEKS_PER_MONTH.toFixed(2)} weeks (52 ÷ 12). Amounts are read in
          the currency you picked — nothing is converted. Reclaimed hours are counted at full loaded
          cost, which only holds if that time genuinely moves to billable or revenue work; if it
          turns into slack instead, treat the figure as capacity, not cash.
        </p>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →