Web InnoventixFreeCode

Pricing Table

Original · free

feature comparison pricing table

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

import { Fragment, useId, useRef, useState, type KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type BillingCycle = "monthly" | "annual";
type PlanId = "starter" | "growth" | "scale";
type FeatureValue = boolean | string;

interface Plan {
  id: PlanId;
  name: string;
  tagline: string;
  monthly: number;
  annual: number;
  cta: string;
  popular?: boolean;
}

interface FeatureRow {
  label: string;
  hint?: string;
  values: Record<PlanId, FeatureValue>;
}

interface FeatureCategory {
  id: string;
  name: string;
  rows: FeatureRow[];
}

const PLANS: Plan[] = [
  {
    id: "starter",
    name: "Starter",
    tagline: "Side projects and kicking the tires.",
    monthly: 0,
    annual: 0,
    cta: "Start free",
  },
  {
    id: "growth",
    name: "Growth",
    tagline: "Teams shipping to production every week.",
    monthly: 29,
    annual: 23,
    cta: "Start 14-day trial",
    popular: true,
  },
  {
    id: "scale",
    name: "Scale",
    tagline: "On-call rotations and compliance reviews.",
    monthly: 89,
    annual: 71,
    cta: "Talk to sales",
  },
];

const CATEGORIES: FeatureCategory[] = [
  {
    id: "monitoring",
    name: "Monitoring",
    rows: [
      { label: "Uptime monitors", values: { starter: "3", growth: "50", scale: "Unlimited" } },
      { label: "Fastest check interval", values: { starter: "5 min", growth: "30 sec", scale: "10 sec" } },
      { label: "Global check regions", values: { starter: "1", growth: "8", scale: "16" } },
      { label: "Multi-step API checks", values: { starter: false, growth: true, scale: true } },
      { label: "Cron & heartbeat checks", values: { starter: false, growth: true, scale: true } },
    ],
  },
  {
    id: "alerting",
    name: "Alerting",
    rows: [
      { label: "Email & Slack alerts", values: { starter: true, growth: true, scale: true } },
      { label: "SMS & voice call", values: { starter: false, growth: true, scale: true } },
      { label: "On-call scheduling", values: { starter: false, growth: false, scale: true } },
      { label: "Escalation policies", values: { starter: false, growth: "Basic", scale: "Multi-tier" } },
    ],
  },
  {
    id: "status",
    name: "Status pages",
    rows: [
      { label: "Public status page", values: { starter: false, growth: "1", scale: "Unlimited" } },
      { label: "Custom domain", values: { starter: false, growth: true, scale: true } },
      { label: "Subscriber notifications", values: { starter: false, growth: "500", scale: "Unlimited" } },
    ],
  },
  {
    id: "security",
    name: "Data & security",
    rows: [
      { label: "Metrics retention", values: { starter: "7 days", growth: "90 days", scale: "13 months" } },
      { label: "SSO / SAML", values: { starter: false, growth: false, scale: true } },
      { label: "Audit log", values: { starter: false, growth: false, scale: true } },
      { label: "Uptime SLA", values: { starter: false, growth: "99.9%", scale: "99.99%" } },
    ],
  },
  {
    id: "support",
    name: "Support",
    rows: [
      { label: "Docs & community", values: { starter: true, growth: true, scale: true } },
      { label: "Priority support", values: { starter: false, growth: true, scale: true } },
      { label: "Dedicated success manager", values: { starter: false, growth: false, scale: true } },
    ],
  },
];

const CYCLES: { id: BillingCycle; label: string }[] = [
  { id: "monthly", label: "Monthly" },
  { id: "annual", label: "Annual" },
];

function IconCheck({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
      <path d="M4 10.5l3.6 3.6L16 5.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function IconDash({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
      <path d="M5 10h10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
    </svg>
  );
}

function IconChevron({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
      <path d="M6 8l4 4 4-4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function IconSpark({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className={className}>
      <path d="M10 1l1.9 5L17 8l-5.1 2L10 15l-1.9-5L3 8l5.1-2L10 1z" />
    </svg>
  );
}

export default function PricingComparisonTable() {
  const reduce = useReducedMotion();
  const uid = useId().replace(/:/g, "");

  const [billing, setBilling] = useState<BillingCycle>("annual");
  const [highlight, setHighlight] = useState<PlanId | null>("growth");
  const [open, setOpen] = useState<Record<string, boolean>>(() =>
    Object.fromEntries(CATEGORIES.map((c) => [c.id, true])),
  );

  const cycleRefs = useRef<Record<BillingCycle, HTMLButtonElement | null>>({
    monthly: null,
    annual: null,
  });

  const allOpen = CATEGORIES.every((c) => open[c.id]);

  const t = (d: number) =>
    reduce ? { duration: 0 } : { duration: d, ease: [0.22, 1, 0.36, 1] as const };

  function onCycleKeyDown(e: KeyboardEvent<HTMLButtonElement>) {
    const forward = e.key === "ArrowRight" || e.key === "ArrowDown";
    const back = e.key === "ArrowLeft" || e.key === "ArrowUp";
    if (!forward && !back) return;
    e.preventDefault();
    const idx = CYCLES.findIndex((c) => c.id === billing);
    const next = CYCLES[(idx + (forward ? 1 : -1) + CYCLES.length) % CYCLES.length];
    setBilling(next.id);
    cycleRefs.current[next.id]?.focus();
  }

  function toggleAll() {
    const target = !allOpen;
    setOpen(Object.fromEntries(CATEGORIES.map((c) => [c.id, target])));
  }

  function colHighlight(planId: PlanId): string {
    if (highlight !== planId) return "";
    return "bg-indigo-50/70 dark:bg-indigo-500/10";
  }

  function renderValue(v: FeatureValue) {
    if (v === true) {
      return (
        <>
          <IconCheck className="mx-auto h-5 w-5 text-emerald-600 dark:text-emerald-400" />
          <span className="sr-only">Included</span>
        </>
      );
    }
    if (v === false) {
      return (
        <>
          <IconDash className="mx-auto h-5 w-5 text-slate-300 dark:text-zinc-600" />
          <span className="sr-only">Not included</span>
        </>
      );
    }
    return <span className="text-sm font-semibold text-slate-800 dark:text-slate-100">{v}</span>;
  }

  const focusRing =
    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-950";

  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 via-white to-slate-50 px-4 py-20 sm:px-6 sm:py-28 lg:px-8 dark:from-zinc-950 dark:via-zinc-950 dark:to-black">
      <style>{`
        @keyframes mrtp-float {
          0%, 100% { transform: translate3d(0, 0, 0); }
          50% { transform: translate3d(0, -20px, 0); }
        }
        @keyframes mrtp-sweep {
          0% { background-position: 0% 50%; }
          100% { background-position: 200% 50%; }
        }
        .mrtp-blob { animation: mrtp-float 11s ease-in-out infinite; }
        .mrtp-badge-sheen {
          background-size: 200% 100%;
          animation: mrtp-sweep 4.5s linear infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .mrtp-blob, .mrtp-badge-sheen { animation: none !important; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="mrtp-blob pointer-events-none absolute -top-24 left-1/2 h-72 w-72 -translate-x-1/2 rounded-full bg-indigo-400/20 blur-3xl dark:bg-indigo-600/20"
      />
      <div
        aria-hidden="true"
        className="mrtp-blob pointer-events-none absolute bottom-0 right-[8%] h-64 w-64 rounded-full bg-violet-400/15 blur-3xl dark:bg-violet-600/15"
        style={{ animationDelay: "-3.5s" }}
      />

      <div className="relative mx-auto max-w-6xl">
        <motion.div
          initial={reduce ? false : { opacity: 0, y: 16 }}
          whileInView={{ opacity: 1, y: 0 }}
          viewport={{ once: true, margin: "-80px" }}
          transition={t(0.6)}
          className="mx-auto max-w-2xl text-center"
        >
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-indigo-600 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-indigo-300">
            <IconSpark className="h-3.5 w-3.5" />
            Meridian pricing
          </span>
          <h2 className="mt-5 text-balance text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
            Monitoring that scales from your first check to your busiest launch
          </h2>
          <p className="mx-auto mt-4 max-w-xl text-pretty text-base leading-relaxed text-slate-600 dark:text-slate-400">
            Every plan includes unlimited teammates, the full REST &amp; Terraform API, and zero
            per-seat fees. You upgrade only when your infrastructure does.
          </p>
        </motion.div>

        <motion.div
          initial={reduce ? false : { opacity: 0, y: 16 }}
          whileInView={{ opacity: 1, y: 0 }}
          viewport={{ once: true, margin: "-80px" }}
          transition={t(0.6)}
          className="mt-9 flex flex-wrap items-center justify-center gap-4"
        >
          <div
            role="radiogroup"
            aria-label="Billing cycle"
            className="inline-flex items-center rounded-full border border-slate-200 bg-white p-1 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
          >
            {CYCLES.map((c) => {
              const active = billing === c.id;
              return (
                <button
                  key={c.id}
                  type="button"
                  role="radio"
                  aria-checked={active}
                  tabIndex={active ? 0 : -1}
                  ref={(el) => {
                    cycleRefs.current[c.id] = el;
                  }}
                  onClick={() => setBilling(c.id)}
                  onKeyDown={onCycleKeyDown}
                  className={`relative rounded-full px-5 py-1.5 text-sm font-semibold transition-colors ${focusRing} ${
                    active
                      ? "bg-slate-900 text-white dark:bg-white dark:text-zinc-900"
                      : "text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-white"
                  }`}
                >
                  {c.label}
                </button>
              );
            })}
          </div>
          <span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300">
            <IconCheck className="h-3.5 w-3.5" />
            Save 20% annually
          </span>
        </motion.div>

        <motion.div
          initial={reduce ? false : { opacity: 0, y: 24 }}
          whileInView={{ opacity: 1, y: 0 }}
          viewport={{ once: true, margin: "-60px" }}
          transition={t(0.7)}
          className="mt-10 overflow-hidden rounded-3xl border border-slate-200 bg-white/80 shadow-xl shadow-slate-900/5 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/60 dark:shadow-black/40"
        >
          <div className="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 px-5 py-4 dark:border-zinc-800">
            <p className="text-sm font-semibold text-slate-700 dark:text-slate-200">
              Compare every feature
            </p>
            <button
              type="button"
              onClick={toggleAll}
              aria-pressed={allOpen}
              className={`inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-zinc-800 dark:hover:text-white ${focusRing}`}
            >
              <IconChevron
                className={`h-4 w-4 transition-transform duration-300 ${allOpen ? "rotate-180" : ""}`}
              />
              {allOpen ? "Collapse all" : "Expand all"}
            </button>
          </div>

          <div className="overflow-x-auto">
            <table className="w-full min-w-[820px] border-collapse text-left">
              <caption className="sr-only">
                Meridian plan feature comparison across Starter, Growth, and Scale tiers
              </caption>

              <thead>
                <tr className="align-bottom">
                  <th
                    scope="col"
                    className="sticky left-0 z-20 w-[34%] min-w-[220px] bg-white/95 px-5 pb-6 pt-6 align-bottom backdrop-blur dark:bg-zinc-900/95"
                  >
                    <span className="text-sm font-semibold text-slate-500 dark:text-slate-400">
                      Choose a plan to highlight
                    </span>
                  </th>

                  {PLANS.map((p) => {
                    const active = highlight === p.id;
                    const price = billing === "annual" ? p.annual : p.monthly;
                    const emphasize = active || p.popular;
                    return (
                      <th
                        key={p.id}
                        scope="col"
                        className={`relative w-[22%] min-w-[190px] px-4 pb-6 pt-6 align-top ${colHighlight(
                          p.id,
                        )} ${p.popular ? "border-x border-indigo-200 dark:border-indigo-500/30" : ""}`}
                      >
                        {p.popular && (
                          <span className="mrtp-badge-sheen absolute -top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-gradient-to-r from-indigo-500 via-violet-500 to-indigo-500 px-3 py-1 text-[11px] font-bold uppercase tracking-wide text-white shadow-sm">
                            Most popular
                          </span>
                        )}

                        <button
                          type="button"
                          aria-pressed={active}
                          aria-label={`Highlight the ${p.name} plan column`}
                          onClick={() => setHighlight((prev) => (prev === p.id ? null : p.id))}
                          className={`relative block w-full rounded-2xl border p-4 text-left transition-colors ${focusRing} ${
                            active
                              ? "border-indigo-500 bg-white shadow-sm dark:border-indigo-400 dark:bg-zinc-900"
                              : "border-transparent hover:border-slate-200 hover:bg-slate-50 dark:hover:border-zinc-700 dark:hover:bg-zinc-800/50"
                          }`}
                        >
                          <span className="flex items-center justify-between">
                            <span className="text-base font-bold text-slate-900 dark:text-white">
                              {p.name}
                            </span>
                            <span
                              className={`flex h-5 w-5 items-center justify-center rounded-full border transition-colors ${
                                active
                                  ? "border-indigo-500 bg-indigo-500 text-white dark:border-indigo-400 dark:bg-indigo-400 dark:text-zinc-900"
                                  : "border-slate-300 text-transparent dark:border-zinc-600"
                              }`}
                            >
                              <IconCheck className="h-3.5 w-3.5" />
                            </span>
                          </span>

                          <span className="relative mt-4 block h-16">
                            <AnimatePresence mode="wait" initial={false}>
                              <motion.span
                                key={billing}
                                initial={reduce ? false : { opacity: 0, y: 8 }}
                                animate={{ opacity: 1, y: 0 }}
                                exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
                                transition={t(0.22)}
                                className="absolute inset-0 block"
                              >
                                {p.monthly === 0 ? (
                                  <>
                                    <span className="text-4xl font-extrabold tracking-tight text-slate-900 dark:text-white">
                                      $0
                                    </span>
                                    <span className="mt-1 block text-xs font-medium text-slate-500 dark:text-slate-400">
                                      Free forever
                                    </span>
                                  </>
                                ) : (
                                  <>
                                    <span className="flex items-baseline gap-1.5">
                                      <span className="text-4xl font-extrabold tracking-tight text-slate-900 dark:text-white">
                                        ${price}
                                      </span>
                                      <span className="text-sm font-medium text-slate-500 dark:text-slate-400">
                                        /mo
                                      </span>
                                      {billing === "annual" && (
                                        <span className="text-xs font-medium text-slate-400 line-through dark:text-zinc-500">
                                          ${p.monthly}
                                        </span>
                                      )}
                                    </span>
                                    <span className="mt-1 block text-xs font-medium text-slate-500 dark:text-slate-400">
                                      {billing === "annual" ? "billed annually" : "billed monthly"}
                                    </span>
                                  </>
                                )}
                              </motion.span>
                            </AnimatePresence>
                          </span>

                          <span className="mt-4 block text-sm leading-snug text-slate-500 dark:text-slate-400">
                            {p.tagline}
                          </span>
                        </button>

                        <button
                          type="button"
                          className={`mt-4 w-full rounded-xl px-4 py-2.5 text-sm font-semibold transition-colors ${focusRing} ${
                            emphasize
                              ? "bg-indigo-600 text-white shadow-sm hover:bg-indigo-500"
                              : "bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700"
                          }`}
                        >
                          {p.cta}
                        </button>
                      </th>
                    );
                  })}
                </tr>
              </thead>

              {CATEGORIES.map((cat) => {
                const rowsId = `${uid}-${cat.id}-rows`;
                const isOpen = open[cat.id];
                return (
                  <Fragment key={cat.id}>
                    <tbody className="border-t border-slate-200 dark:border-zinc-800">
                      <tr>
                        <th scope="colgroup" colSpan={PLANS.length + 1} className="p-0">
                          <button
                            type="button"
                            aria-expanded={isOpen}
                            aria-controls={rowsId}
                            onClick={() =>
                              setOpen((prev) => ({ ...prev, [cat.id]: !prev[cat.id] }))
                            }
                            className={`flex w-full items-center gap-2 bg-slate-50/80 px-5 py-3 text-left text-sm font-bold uppercase tracking-wide text-slate-700 transition-colors hover:bg-slate-100 dark:bg-zinc-900/70 dark:text-slate-200 dark:hover:bg-zinc-800/70 ${focusRing}`}
                          >
                            <IconChevron
                              className={`h-4 w-4 text-slate-400 transition-transform duration-300 dark:text-slate-500 ${
                                isOpen ? "" : "-rotate-90"
                              }`}
                            />
                            {cat.name}
                            <span className="text-xs font-medium normal-case text-slate-400 dark:text-slate-500">
                              {cat.rows.length} features
                            </span>
                          </button>
                        </th>
                      </tr>
                    </tbody>

                    <tbody id={rowsId} hidden={!isOpen}>
                      {cat.rows.map((row) => (
                        <tr
                          key={row.label}
                          className="group border-t border-slate-100 dark:border-zinc-800/70"
                        >
                          <th
                            scope="row"
                            className="sticky left-0 z-10 w-[34%] min-w-[220px] bg-white/95 px-5 py-3.5 text-left text-sm font-medium text-slate-700 backdrop-blur transition-colors group-hover:bg-slate-50 dark:bg-zinc-900/95 dark:text-slate-300 dark:group-hover:bg-zinc-800/50"
                          >
                            {row.label}
                          </th>
                          {PLANS.map((p) => (
                            <td
                              key={p.id}
                              className={`w-[22%] min-w-[190px] px-4 py-3.5 text-center transition-colors group-hover:bg-slate-50/60 dark:group-hover:bg-zinc-800/30 ${colHighlight(
                                p.id,
                              )} ${
                                p.popular
                                  ? "border-x border-indigo-100 dark:border-indigo-500/20"
                                  : ""
                              }`}
                            >
                              {renderValue(row.values[p.id])}
                            </td>
                          ))}
                        </tr>
                      ))}
                    </tbody>
                  </Fragment>
                );
              })}
            </table>
          </div>

          <div className="border-t border-slate-200 px-5 py-4 text-center text-xs text-slate-500 dark:border-zinc-800 dark:text-slate-400">
            All prices in USD. Cancel anytime — paid plans include a 14-day money-back guarantee.
          </div>
        </motion.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 →