Web InnoventixFreeCode

Calculator Pricing

Original · free

interactive pricing calculator

byWeb InnoventixReact + Tailwind
calcpricingcalculators
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-pricing.json
calc-pricing.tsx
"use client";

import { useEffect, useId, useState } from "react";
import {
  AnimatePresence,
  animate,
  motion,
  useMotionValue,
  useReducedMotion,
  useTransform,
} from "motion/react";

type ProjectKey = "landing" | "marketing" | "store" | "app";
type AddOnKey = "copy" | "seo" | "illustration" | "motion" | "analytics";
type SpeedKey = "standard" | "priority" | "rush";

type Project = {
  name: string;
  blurb: string;
  base: number;
  pages: number;
  weeks: number;
};

type AddOn = {
  name: string;
  note: string;
  price: number;
};

type Speed = {
  name: string;
  note: string;
  multiplier: number;
  timeFactor: number;
};

type Row = {
  id: string;
  label: string;
  detail: string;
  amount: number;
};

const PAGE_RATE = 320;
const CARE_RATE = 180;
const DEPOSIT_SHARE = 0.4;
const MAX_EXTRA_PAGES = 16;

const PROJECT_ORDER: ProjectKey[] = ["landing", "marketing", "store", "app"];

const PROJECTS: Record<ProjectKey, Project> = {
  landing: {
    name: "Landing page",
    blurb: "One page, one conversion goal, built to be measured.",
    base: 1400,
    pages: 1,
    weeks: 2,
  },
  marketing: {
    name: "Marketing site",
    blurb: "Multi-page site with a blog and a CMS your team can drive.",
    base: 3800,
    pages: 5,
    weeks: 4,
  },
  store: {
    name: "Online store",
    blurb: "Catalogue, cart and checkout wired to your payment provider.",
    base: 6500,
    pages: 8,
    weeks: 7,
  },
  app: {
    name: "Web app MVP",
    blurb: "Auth, dashboard and the first honest slice of the product.",
    base: 12000,
    pages: 10,
    weeks: 11,
  },
};

const ADDON_ORDER: AddOnKey[] = ["copy", "seo", "illustration", "motion", "analytics"];

const ADDONS: Record<AddOnKey, AddOn> = {
  copy: {
    name: "Copywriting",
    note: "Two research interviews, then every word written for you.",
    price: 850,
  },
  seo: {
    name: "SEO foundation",
    note: "Schema, sitemaps, metadata and a Core Web Vitals pass.",
    price: 600,
  },
  illustration: {
    name: "Illustration set",
    note: "Six custom vector scenes drawn around your brand.",
    price: 1100,
  },
  motion: {
    name: "Motion & interaction",
    note: "Scroll choreography and micro-interactions, reduced-motion safe.",
    price: 750,
  },
  analytics: {
    name: "Analytics wiring",
    note: "Events, funnels and a dashboard you will actually open.",
    price: 380,
  },
};

const SPEED_ORDER: SpeedKey[] = ["standard", "priority", "rush"];

const SPEEDS: Record<SpeedKey, Speed> = {
  standard: { name: "Standard", note: "Normal queue, weekly check-ins", multiplier: 1, timeFactor: 1 },
  priority: { name: "Priority", note: "Queue jump, reviews twice a week", multiplier: 1.25, timeFactor: 0.75 },
  rush: { name: "Rush", note: "Dedicated sprint, daily stand-ups", multiplier: 1.5, timeFactor: 0.55 },
};

const gbp = (value: number): string => `£${Math.round(value).toLocaleString("en-GB")}`;

const panelClass =
  "rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm sm:p-6 dark:border-zinc-800 dark:bg-zinc-900/50";

const legendClass =
  "flex items-center gap-2 text-sm font-semibold tracking-tight text-zinc-900 dark:text-zinc-100";

const stepClass =
  "grid h-5 w-5 place-items-center rounded-md bg-zinc-900 text-[10px] font-bold text-white tabular-nums dark:bg-zinc-100 dark:text-zinc-900";

const focusRing =
  "peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-indigo-600 dark:peer-focus-visible:outline-indigo-400";

function CheckIcon({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 20 20" fill="none" aria-hidden="true">
      <path
        d="M4.5 10.5 8 14l7.5-8"
        stroke="currentColor"
        strokeWidth="2.4"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function AnimatedAmount({
  value,
  motionOn,
  className,
}: {
  value: number;
  motionOn: boolean;
  className?: string;
}) {
  const raw = useMotionValue(value);
  const text = useTransform(raw, (current: number) => Math.round(current).toLocaleString("en-GB"));

  useEffect(() => {
    if (!motionOn) {
      raw.set(value);
      return;
    }
    const controls = animate(raw, value, { duration: 0.45, ease: "easeOut" });
    return () => controls.stop();
  }, [value, motionOn, raw]);

  return (
    <span className={className} aria-hidden="true">
      £<motion.span>{text}</motion.span>
    </span>
  );
}

export default function CalcPricing() {
  const uid = useId();
  const reduce = useReducedMotion();
  const motionOn = !reduce;

  const [projectKey, setProjectKey] = useState<ProjectKey>("marketing");
  const [extraPages, setExtraPages] = useState<number>(3);
  const [selected, setSelected] = useState<Record<AddOnKey, boolean>>({
    copy: false,
    seo: true,
    illustration: false,
    motion: false,
    analytics: false,
  });
  const [speedKey, setSpeedKey] = useState<SpeedKey>("standard");
  const [care, setCare] = useState<boolean>(true);
  const [copied, setCopied] = useState<boolean>(false);

  const project = PROJECTS[projectKey];
  const speed = SPEEDS[speedKey];

  const addOnKeys = ADDON_ORDER.filter((key) => selected[key]);
  const addOnsTotal = addOnKeys.reduce((sum, key) => sum + ADDONS[key].price, 0);
  const pagesTotal = extraPages * PAGE_RATE;
  const subtotal = project.base + pagesTotal + addOnsTotal;
  const uplift = Math.round(subtotal * (speed.multiplier - 1));
  const total = subtotal + uplift;
  const deposit = Math.round(total * DEPOSIT_SHARE);
  const weeks = Math.max(
    1,
    Math.round((project.weeks + extraPages / 5 + addOnKeys.length * 0.5) * speed.timeFactor),
  );

  const rows: Row[] = [
    {
      id: "base",
      label: project.name,
      detail: `${project.pages} page${project.pages === 1 ? "" : "s"} included`,
      amount: project.base,
    },
  ];
  if (extraPages > 0) {
    rows.push({
      id: "pages",
      label: "Extra pages",
      detail: `${extraPages} × ${gbp(PAGE_RATE)}`,
      amount: pagesTotal,
    });
  }
  for (const key of addOnKeys) {
    rows.push({ id: key, label: ADDONS[key].name, detail: "Add-on", amount: ADDONS[key].price });
  }
  if (uplift > 0) {
    rows.push({
      id: "speed",
      label: `${speed.name} delivery`,
      detail: `+${Math.round((speed.multiplier - 1) * 100)}% of ${gbp(subtotal)}`,
      amount: uplift,
    });
  }

  useEffect(() => {
    if (!copied) return;
    const timer = window.setTimeout(() => setCopied(false), 2200);
    return () => window.clearTimeout(timer);
  }, [copied]);

  const toggleAddOn = (key: AddOnKey) => {
    setSelected((previous) => ({ ...previous, [key]: !previous[key] }));
  };

  const reset = () => {
    setProjectKey("marketing");
    setExtraPages(3);
    setSelected({ copy: false, seo: true, illustration: false, motion: false, analytics: false });
    setSpeedKey("standard");
    setCare(true);
  };

  const handleCopy = async () => {
    const lines = [
      "Project estimate",
      ...rows.map((row) => `${row.label} (${row.detail}) — ${gbp(row.amount)}`),
      `Total — ${gbp(total)} + VAT`,
      `Deposit to start (40%) — ${gbp(deposit)}`,
      `Estimated timeline — about ${weeks} week${weeks === 1 ? "" : "s"}`,
      care ? `Care plan — ${gbp(CARE_RATE)} per month` : "Care plan — not included",
    ];
    try {
      await navigator.clipboard.writeText(lines.join("\n"));
      setCopied(true);
    } catch {
      setCopied(false);
    }
  };

  const sliderPercent = (extraPages / MAX_EXTRA_PAGES) * 100;

  return (
    <section className="relative w-full overflow-hidden bg-white px-5 py-20 text-zinc-900 sm:px-8 md:py-28 dark:bg-zinc-950 dark:text-zinc-50">
      <style>{`
        @keyframes cpx-sheen {
          0% { transform: translateX(-120%); }
          100% { transform: translateX(420%); }
        }
        @keyframes cpx-pulse {
          0%, 100% { opacity: 0.35; transform: scale(1); }
          50% { opacity: 1; transform: scale(1.6); }
        }
        .cpx-sheen { animation: cpx-sheen 4.2s ease-in-out infinite; }
        .cpx-pulse { animation: cpx-pulse 2.4s ease-in-out infinite; }
        .cpx-range {
          -webkit-appearance: none;
          appearance: none;
          width: 100%;
          height: 8px;
          border-radius: 9999px;
          background-image: linear-gradient(currentColor, currentColor);
          background-repeat: no-repeat;
          cursor: pointer;
        }
        .cpx-range::-webkit-slider-runnable-track {
          height: 8px;
          border-radius: 9999px;
          background: transparent;
        }
        .cpx-range::-moz-range-track {
          height: 8px;
          border-radius: 9999px;
          background: transparent;
        }
        .cpx-range::-webkit-slider-thumb {
          -webkit-appearance: none;
          appearance: none;
          margin-top: -7px;
          height: 22px;
          width: 22px;
          border-radius: 9999px;
          background: #ffffff;
          border: 2px solid currentColor;
          box-shadow: 0 1px 4px rgba(9, 9, 11, 0.3);
          transition: transform 0.16s ease;
        }
        .cpx-range::-moz-range-thumb {
          height: 22px;
          width: 22px;
          border-radius: 9999px;
          background: #ffffff;
          border: 2px solid currentColor;
          box-shadow: 0 1px 4px rgba(9, 9, 11, 0.3);
          transition: transform 0.16s ease;
        }
        .cpx-range:active::-webkit-slider-thumb { transform: scale(1.14); }
        .cpx-range:active::-moz-range-thumb { transform: scale(1.14); }
        @media (prefers-reduced-motion: reduce) {
          .cpx-sheen, .cpx-pulse { animation: none !important; }
          .cpx-range::-webkit-slider-thumb { transition: none !important; }
          .cpx-range::-moz-range-thumb { transition: none !important; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 bg-[radial-gradient(55rem_32rem_at_50%_-8%,rgba(99,102,241,0.12),transparent_70%)] dark:bg-[radial-gradient(55rem_32rem_at_50%_-8%,rgba(129,140,248,0.16),transparent_70%)]"
      />

      <div className="relative mx-auto max-w-6xl">
        <header className="max-w-2xl">
          <p className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white/70 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-zinc-600 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-400">
            <span className="relative grid h-2 w-2 place-items-center">
              <span className="cpx-pulse absolute h-1.5 w-1.5 rounded-full bg-emerald-500" />
              <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
            </span>
            Live estimate
          </p>
          <h2 className="mt-5 text-3xl font-bold tracking-tight text-zinc-900 sm:text-4xl md:text-5xl dark:text-white">
            Work out the budget before you book the call
          </h2>
          <p className="mt-4 text-base leading-relaxed text-zinc-600 sm:text-lg dark:text-zinc-400">
            Pick a starting point, add what you actually need, and watch the number move. Nothing is
            hidden behind a form — copy the breakdown and take it to your finance team.
          </p>
        </header>

        <div className="mt-12 grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_23rem]">
          <div className="space-y-6">
            <fieldset className={panelClass}>
              <legend className={legendClass}>
                <span className={stepClass}>1</span>
                What are we building?
              </legend>
              <div className="mt-4 grid gap-3 sm:grid-cols-2">
                {PROJECT_ORDER.map((key) => {
                  const item = PROJECTS[key];
                  return (
                    <label key={key} className="group relative block cursor-pointer">
                      <input
                        type="radio"
                        name={`${uid}-project`}
                        value={key}
                        checked={projectKey === key}
                        onChange={() => setProjectKey(key)}
                        className="peer sr-only"
                      />
                      <span
                        className={`flex h-full flex-col rounded-xl border border-zinc-200 bg-zinc-50/60 p-4 transition-colors peer-checked:border-indigo-500 peer-checked:bg-indigo-50 dark:border-zinc-800 dark:bg-zinc-950/40 dark:peer-checked:border-indigo-400 dark:peer-checked:bg-indigo-500/10 ${focusRing}`}
                      >
                        <span className="flex items-center justify-between gap-3">
                          <span className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
                            {item.name}
                          </span>
                          <span className="grid h-4 w-4 shrink-0 place-items-center rounded-full border-2 border-zinc-300 transition-colors group-has-[:checked]:border-indigo-600 dark:border-zinc-600 dark:group-has-[:checked]:border-indigo-400">
                            <span className="h-1.5 w-1.5 scale-0 rounded-full bg-indigo-600 transition-transform group-has-[:checked]:scale-100 dark:bg-indigo-400" />
                          </span>
                        </span>
                        <span className="mt-1.5 text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
                          {item.blurb}
                        </span>
                        <span className="mt-3 flex items-baseline gap-1.5 text-xs text-zinc-500 dark:text-zinc-400">
                          <span className="text-sm font-semibold tabular-nums text-zinc-900 dark:text-zinc-100">
                            from {gbp(item.base)}
                          </span>
                          <span aria-hidden="true">·</span>
                          <span>~{item.weeks} weeks</span>
                        </span>
                      </span>
                    </label>
                  );
                })}
              </div>
            </fieldset>

            <div className={panelClass}>
              <div className="flex flex-wrap items-center justify-between gap-3">
                <label
                  htmlFor={`${uid}-pages`}
                  className="flex items-center gap-2 text-sm font-semibold tracking-tight text-zinc-900 dark:text-zinc-100"
                >
                  <span className={stepClass}>2</span>
                  Extra pages
                </label>
                <output
                  htmlFor={`${uid}-pages`}
                  className="rounded-lg bg-zinc-100 px-2.5 py-1 text-sm font-semibold tabular-nums text-zinc-900 dark:bg-zinc-800 dark:text-zinc-100"
                >
                  {extraPages} × {gbp(PAGE_RATE)}
                </output>
              </div>
              <p id={`${uid}-pages-help`} className="mt-2 text-xs text-zinc-500 dark:text-zinc-400">
                {project.name} includes {project.pages} page{project.pages === 1 ? "" : "s"}. Add more
                for anything beyond that — each one is designed, written into the CMS and tested.
              </p>
              <input
                id={`${uid}-pages`}
                type="range"
                min={0}
                max={MAX_EXTRA_PAGES}
                step={1}
                value={extraPages}
                onChange={(event) => setExtraPages(Number(event.target.value))}
                aria-describedby={`${uid}-pages-help`}
                aria-valuetext={`${extraPages} extra pages, ${gbp(pagesTotal)}`}
                style={{ backgroundSize: `${sliderPercent}% 100%` }}
                className="cpx-range mt-5 bg-zinc-200 text-indigo-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-indigo-600 dark:bg-zinc-800 dark:text-indigo-400 dark:focus-visible:outline-indigo-400"
              />
              <div className="mt-2 flex justify-between text-[11px] font-medium tabular-nums text-zinc-400 dark:text-zinc-500">
                <span>0</span>
                <span>8</span>
                <span>{MAX_EXTRA_PAGES}</span>
              </div>
            </div>

            <fieldset className={panelClass}>
              <legend className={legendClass}>
                <span className={stepClass}>3</span>
                Add-ons
              </legend>
              <div className="mt-4 space-y-2.5">
                {ADDON_ORDER.map((key) => {
                  const item = ADDONS[key];
                  return (
                    <label key={key} className="group relative block cursor-pointer">
                      <input
                        type="checkbox"
                        checked={selected[key]}
                        onChange={() => toggleAddOn(key)}
                        className="peer sr-only"
                      />
                      <span
                        className={`flex items-start gap-3 rounded-xl border border-zinc-200 bg-zinc-50/60 p-4 transition-colors peer-checked:border-violet-500 peer-checked:bg-violet-50 dark:border-zinc-800 dark:bg-zinc-950/40 dark:peer-checked:border-violet-400 dark:peer-checked:bg-violet-500/10 ${focusRing}`}
                      >
                        <span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-md border-2 border-zinc-300 text-white transition-colors group-has-[:checked]:border-violet-600 group-has-[:checked]:bg-violet-600 dark:border-zinc-600 dark:group-has-[:checked]:border-violet-500 dark:group-has-[:checked]:bg-violet-500">
                          <CheckIcon className="h-3.5 w-3.5 scale-0 transition-transform group-has-[:checked]:scale-100" />
                        </span>
                        <span className="min-w-0 flex-1">
                          <span className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
                            <span className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
                              {item.name}
                            </span>
                            <span className="text-sm font-semibold tabular-nums text-zinc-900 dark:text-zinc-100">
                              +{gbp(item.price)}
                            </span>
                          </span>
                          <span className="mt-1 block text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
                            {item.note}
                          </span>
                        </span>
                      </span>
                    </label>
                  );
                })}
              </div>
            </fieldset>

            <fieldset className={panelClass}>
              <legend className={legendClass}>
                <span className={stepClass}>4</span>
                How fast do you need it?
              </legend>
              <div className="mt-4 grid gap-2.5 sm:grid-cols-3">
                {SPEED_ORDER.map((key) => {
                  const item = SPEEDS[key];
                  return (
                    <label key={key} className="group relative block cursor-pointer">
                      <input
                        type="radio"
                        name={`${uid}-speed`}
                        value={key}
                        checked={speedKey === key}
                        onChange={() => setSpeedKey(key)}
                        className="peer sr-only"
                      />
                      <span
                        className={`flex h-full flex-col rounded-xl border border-zinc-200 bg-zinc-50/60 px-4 py-3 text-center transition-colors peer-checked:border-zinc-900 peer-checked:bg-zinc-900 dark:border-zinc-800 dark:bg-zinc-950/40 dark:peer-checked:border-zinc-100 dark:peer-checked:bg-zinc-100 ${focusRing}`}
                      >
                        <span className="text-sm font-semibold text-zinc-900 group-has-[:checked]:text-white dark:text-zinc-100 dark:group-has-[:checked]:text-zinc-900">
                          {item.name}
                          {item.multiplier > 1 ? (
                            <span className="ml-1 text-amber-600 group-has-[:checked]:text-amber-300 dark:text-amber-400 dark:group-has-[:checked]:text-amber-600">
                              +{Math.round((item.multiplier - 1) * 100)}%
                            </span>
                          ) : null}
                        </span>
                        <span className="mt-1 text-[11px] leading-snug text-zinc-500 group-has-[:checked]:text-zinc-300 dark:text-zinc-400 dark:group-has-[:checked]:text-zinc-600">
                          {item.note}
                        </span>
                      </span>
                    </label>
                  );
                })}
              </div>

              <label className="group mt-4 flex cursor-pointer items-center justify-between gap-4 rounded-xl border border-zinc-200 bg-zinc-50/60 p-4 dark:border-zinc-800 dark:bg-zinc-950/40">
                <span className="min-w-0">
                  <span className="block text-sm font-semibold text-zinc-900 dark:text-zinc-100">
                    Care plan — {gbp(CARE_RATE)}/month
                  </span>
                  <span className="mt-1 block text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
                    Hosting, security updates, uptime alerts and four hours of changes each month.
                  </span>
                </span>
                <input
                  type="checkbox"
                  role="switch"
                  checked={care}
                  onChange={() => setCare((previous) => !previous)}
                  aria-label={`Care plan at ${gbp(CARE_RATE)} per month`}
                  className="peer sr-only"
                />
                <span
                  className={`relative h-6 w-11 shrink-0 rounded-full bg-zinc-300 transition-colors peer-checked:bg-emerald-600 dark:bg-zinc-700 dark:peer-checked:bg-emerald-500 ${focusRing}`}
                >
                  <span className="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform group-has-[:checked]:translate-x-5" />
                </span>
              </label>
            </fieldset>
          </div>

          <aside className="lg:sticky lg:top-6">
            <div className="relative overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-xl shadow-zinc-900/5 dark:border-zinc-800 dark:bg-zinc-900">
              <div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 h-px overflow-hidden">
                <div className="cpx-sheen h-px w-1/4 bg-gradient-to-r from-transparent via-indigo-500 to-transparent dark:via-indigo-400" />
              </div>

              <div className="p-6">
                <div className="flex items-center justify-between gap-3">
                  <h3 className="text-sm font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">
                    Your estimate
                  </h3>
                  <button
                    type="button"
                    onClick={reset}
                    className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs font-medium text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-900 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100 dark:focus-visible:outline-indigo-400"
                  >
                    <svg className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="none" aria-hidden="true">
                      <path
                        d="M15.5 6.5A6 6 0 1 0 16 10M15.5 3v4h-4"
                        stroke="currentColor"
                        strokeWidth="1.7"
                        strokeLinecap="round"
                        strokeLinejoin="round"
                      />
                    </svg>
                    Reset
                  </button>
                </div>

                <ul className="mt-4 space-y-0 text-sm">
                  <AnimatePresence initial={false}>
                    {rows.map((row) => (
                      <motion.li
                        key={row.id}
                        layout={motionOn}
                        initial={{ opacity: 0, height: 0 }}
                        animate={{ opacity: 1, height: "auto" }}
                        exit={{ opacity: 0, height: 0 }}
                        transition={{ duration: motionOn ? 0.24 : 0, ease: "easeOut" }}
                        className="overflow-hidden"
                      >
                        <span className="flex items-baseline justify-between gap-4 border-b border-dashed border-zinc-200 py-2.5 dark:border-zinc-800">
                          <span className="min-w-0">
                            <span className="block truncate font-medium text-zinc-800 dark:text-zinc-200">
                              {row.label}
                            </span>
                            <span className="block truncate text-xs text-zinc-500 dark:text-zinc-400">
                              {row.detail}
                            </span>
                          </span>
                          <span className="shrink-0 font-semibold tabular-nums text-zinc-900 dark:text-zinc-100">
                            {gbp(row.amount)}
                          </span>
                        </span>
                      </motion.li>
                    ))}
                  </AnimatePresence>
                </ul>

                <div className="mt-5 flex items-end justify-between gap-4">
                  <div>
                    <p className="text-xs font-medium uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
                      Total
                    </p>
                    <AnimatedAmount
                      value={total}
                      motionOn={motionOn}
                      className="mt-1 block text-4xl font-bold tracking-tight tabular-nums text-zinc-900 dark:text-white"
                    />
                  </div>
                  <p className="pb-1.5 text-xs text-zinc-500 dark:text-zinc-400">+ VAT</p>
                </div>

                <p className="sr-only" aria-live="polite">
                  Estimated total {gbp(total)} plus VAT over about {weeks} week
                  {weeks === 1 ? "" : "s"}. Deposit to start {gbp(deposit)}.
                  {care ? ` Care plan ${gbp(CARE_RATE)} per month.` : " Care plan not included."}
                </p>

                <dl className="mt-5 grid grid-cols-2 gap-2.5 text-xs">
                  <div className="rounded-xl bg-zinc-50 p-3 dark:bg-zinc-800/60">
                    <dt className="text-zinc-500 dark:text-zinc-400">40% to start</dt>
                    <dd className="mt-1 text-base font-semibold tabular-nums text-zinc-900 dark:text-zinc-100">
                      {gbp(deposit)}
                    </dd>
                  </div>
                  <div className="rounded-xl bg-zinc-50 p-3 dark:bg-zinc-800/60">
                    <dt className="text-zinc-500 dark:text-zinc-400">Timeline</dt>
                    <dd className="mt-1 text-base font-semibold tabular-nums text-zinc-900 dark:text-zinc-100">
                      ~{weeks} week{weeks === 1 ? "" : "s"}
                    </dd>
                  </div>
                </dl>

                <AnimatePresence initial={false}>
                  {care ? (
                    <motion.div
                      layout={motionOn}
                      initial={{ opacity: 0, height: 0 }}
                      animate={{ opacity: 1, height: "auto" }}
                      exit={{ opacity: 0, height: 0 }}
                      transition={{ duration: motionOn ? 0.24 : 0, ease: "easeOut" }}
                      className="overflow-hidden"
                    >
                      <p className="mt-2.5 flex items-center justify-between gap-3 rounded-xl bg-emerald-50 p-3 text-xs font-medium text-emerald-800 dark:bg-emerald-500/10 dark:text-emerald-300">
                        <span className="inline-flex items-center gap-1.5">
                          <CheckIcon className="h-3.5 w-3.5" />
                          Care plan, billed after launch
                        </span>
                        <span className="tabular-nums">{gbp(CARE_RATE)}/mo</span>
                      </p>
                    </motion.div>
                  ) : null}
                </AnimatePresence>

                <a
                  href="#"
                  className="mt-5 flex w-full items-center justify-center gap-2 rounded-xl bg-zinc-900 px-4 py-3 text-sm font-semibold text-white transition-colors hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-200 dark:focus-visible:outline-indigo-400"
                >
                  Book a 30-minute scoping call
                  <svg className="h-4 w-4" viewBox="0 0 20 20" fill="none" aria-hidden="true">
                    <path
                      d="M4 10h12m-5-5 5 5-5 5"
                      stroke="currentColor"
                      strokeWidth="1.8"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                    />
                  </svg>
                </a>

                <button
                  type="button"
                  onClick={handleCopy}
                  className="mt-2.5 flex w-full items-center justify-center gap-2 rounded-xl border border-zinc-200 px-4 py-2.5 text-sm font-semibold text-zinc-700 transition-colors hover:bg-zinc-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800 dark:focus-visible:outline-indigo-400"
                >
                  {copied ? (
                    <CheckIcon className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
                  ) : (
                    <svg className="h-4 w-4" viewBox="0 0 20 20" fill="none" aria-hidden="true">
                      <rect
                        x="7"
                        y="7"
                        width="9"
                        height="9"
                        rx="2"
                        stroke="currentColor"
                        strokeWidth="1.6"
                      />
                      <path
                        d="M13 7V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h1"
                        stroke="currentColor"
                        strokeWidth="1.6"
                        strokeLinecap="round"
                      />
                    </svg>
                  )}
                  {copied ? "Breakdown copied" : "Copy the breakdown"}
                </button>
                <span className="sr-only" aria-live="polite">
                  {copied ? "Estimate breakdown copied to your clipboard" : ""}
                </span>
              </div>

              <p className="border-t border-zinc-100 bg-zinc-50/70 px-6 py-4 text-[11px] leading-relaxed text-zinc-500 dark:border-zinc-800 dark:bg-zinc-950/40 dark:text-zinc-400">
                An estimate, not a quote. Final numbers land once we have seen your content,
                integrations and deadline — usually within 5% of what you see here.
              </p>
            </div>
          </aside>
        </div>
      </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 →