Web InnoventixFreeCode

Feature Matrix Pricing

Original · free

feature matrix pricing

byWeb InnoventixReact + Tailwind
pricexfeaturematrixpricing
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/pricex-feature-matrix.json
pricex-feature-matrix.tsx
"use client";

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

type Billing = "monthly" | "annual";
type PlanId = "starter" | "growth" | "scale";
type CellValue = boolean | string;

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

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

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

const PLANS: Plan[] = [
  {
    id: "starter",
    name: "Starter",
    tagline: "For solo builders shipping their first idea.",
    monthly: 19,
    annual: 15,
    cta: "Start free",
  },
  {
    id: "growth",
    name: "Growth",
    tagline: "For teams that need automation and real control.",
    monthly: 49,
    annual: 39,
    cta: "Start 14-day trial",
    featured: true,
  },
  {
    id: "scale",
    name: "Scale",
    tagline: "For orgs with security and compliance needs.",
    monthly: 99,
    annual: 79,
    cta: "Talk to sales",
  },
];

const GROUPS: FeatureGroup[] = [
  {
    id: "core",
    name: "Core workspace",
    rows: [
      { label: "Active projects", values: { starter: "3", growth: "Unlimited", scale: "Unlimited" } },
      { label: "Team members", values: { starter: "2", growth: "15", scale: "Unlimited" } },
      { label: "File storage", values: { starter: "5 GB", growth: "100 GB", scale: "1 TB" } },
      {
        label: "Automations / month",
        hint: "Trigger-based workflows that run without you.",
        values: { starter: "100", growth: "5,000", scale: "Unlimited" },
      },
    ],
  },
  {
    id: "collab",
    name: "Collaboration",
    rows: [
      { label: "Real-time editing", values: { starter: true, growth: true, scale: true } },
      { label: "Guest access", values: { starter: false, growth: true, scale: true } },
      { label: "Comment threads", values: { starter: true, growth: true, scale: true } },
      { label: "Shared workspaces", values: { starter: "1", growth: "10", scale: "Unlimited" } },
    ],
  },
  {
    id: "security",
    name: "Security & admin",
    rows: [
      { label: "SSO / SAML", values: { starter: false, growth: false, scale: true } },
      { label: "Audit logs", values: { starter: false, growth: true, scale: true } },
      { label: "Role-based access", values: { starter: false, growth: true, scale: true } },
      { label: "Data residency", values: { starter: false, growth: false, scale: true } },
    ],
  },
  {
    id: "support",
    name: "Support",
    rows: [
      { label: "Community support", values: { starter: true, growth: true, scale: true } },
      { label: "Priority email", values: { starter: false, growth: true, scale: true } },
      { label: "Dedicated manager", values: { starter: false, growth: false, scale: true } },
      { label: "99.9% uptime SLA", values: { starter: false, growth: false, scale: true } },
    ],
  },
];

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

function CheckIcon() {
  return (
    <svg viewBox="0 0 20 20" fill="none" className="h-4 w-4" aria-hidden="true">
      <path
        d="M4 10.5 8 14.5 16 6"
        stroke="currentColor"
        strokeWidth="2.25"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

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

function ChevronIcon({ open }: { open: boolean }) {
  return (
    <svg
      viewBox="0 0 20 20"
      fill="none"
      aria-hidden="true"
      className={`h-4 w-4 shrink-0 transition-transform duration-300 ${open ? "rotate-180" : ""}`}
    >
      <path d="M5 7.5 10 12.5 15 7.5" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function ArrowIcon() {
  return (
    <svg viewBox="0 0 20 20" fill="none" className="h-4 w-4" aria-hidden="true">
      <path d="M4 10h11m0 0-4-4m4 4-4 4" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

export default function PricexFeatureMatrix() {
  const reduce = useReducedMotion();
  const uid = useId();
  const [billing, setBilling] = useState<Billing>("annual");
  const [open, setOpen] = useState<Record<string, boolean>>(() =>
    Object.fromEntries(GROUPS.map((g) => [g.id, true])),
  );
  const segRefs = useRef<(HTMLButtonElement | null)[]>([]);

  function toggleGroup(id: string) {
    setOpen((prev) => ({ ...prev, [id]: !prev[id] }));
  }

  function handleSegKey(e: KeyboardEvent<HTMLDivElement>) {
    const idx = BILLING_OPTIONS.findIndex((o) => o.id === billing);
    let next = idx;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (idx + 1) % BILLING_OPTIONS.length;
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (idx - 1 + BILLING_OPTIONS.length) % BILLING_OPTIONS.length;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = BILLING_OPTIONS.length - 1;
    else return;
    e.preventDefault();
    setBilling(BILLING_OPTIONS[next].id);
    segRefs.current[next]?.focus();
  }

  const enter = reduce
    ? {}
    : {
        initial: { opacity: 0, y: 20 },
        whileInView: { opacity: 1, y: 0 },
        viewport: { once: true, margin: "-60px" } as const,
      };

  return (
    <section className="relative w-full overflow-hidden bg-white py-20 sm:py-28 dark:bg-slate-950">
      <style>{`
        @keyframes pxfm-float {
          0%, 100% { transform: translateY(0); }
          50% { transform: translateY(-22px); }
        }
        @keyframes pxfm-shimmer {
          0% { background-position: -160% 0; }
          100% { background-position: 160% 0; }
        }
        .pxfm-orb { animation: pxfm-float 14s ease-in-out infinite; }
        .pxfm-shine {
          background-image: linear-gradient(110deg, rgba(255,255,255,0) 25%, rgba(255,255,255,0.65) 50%, rgba(255,255,255,0) 75%);
          background-size: 200% 100%;
          background-repeat: no-repeat;
          animation: pxfm-shimmer 3.6s linear infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .pxfm-orb, .pxfm-shine { animation: none !important; }
        }
      `}</style>

      {/* Decorative background */}
      <div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden">
        <div className="pxfm-orb absolute -top-24 left-1/4 h-72 w-72 rounded-full bg-gradient-to-br from-indigo-300/40 to-violet-300/30 blur-3xl dark:from-indigo-600/20 dark:to-violet-700/10" />
        <div
          className="pxfm-orb absolute -bottom-28 right-1/4 h-72 w-72 rounded-full bg-gradient-to-br from-sky-300/30 to-emerald-200/30 blur-3xl dark:from-sky-700/10 dark:to-emerald-700/10"
          style={{ animationDelay: "-7s" }}
        />
      </div>

      <div className="relative mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
        {/* Header */}
        <motion.div className="mx-auto max-w-2xl text-center" {...enter} transition={{ duration: 0.5, ease: "easeOut" }}>
          <span className="inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-indigo-700 dark:border-indigo-400/30 dark:bg-indigo-500/10 dark:text-indigo-300">
            <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
            Pricing
          </span>
          <h2 className="mt-5 text-balance text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
            Compare every plan, feature by feature
          </h2>
          <p className="mx-auto mt-4 max-w-xl text-pretty text-base leading-relaxed text-slate-600 dark:text-slate-400">
            PriceX scales from your first side project to a company-wide rollout. No hidden add-ons, no surprise
            overages, cancel any time.
          </p>

          {/* Billing toggle */}
          <div className="mt-8 flex flex-col items-center gap-3">
            <div
              role="radiogroup"
              aria-label="Billing period"
              onKeyDown={handleSegKey}
              className="inline-flex items-center rounded-full border border-slate-200 bg-slate-100 p-1 dark:border-slate-800 dark:bg-slate-900"
            >
              {BILLING_OPTIONS.map((o, i) => {
                const active = billing === o.id;
                return (
                  <button
                    key={o.id}
                    ref={(el) => {
                      segRefs.current[i] = el;
                    }}
                    type="button"
                    role="radio"
                    aria-checked={active}
                    tabIndex={active ? 0 : -1}
                    onClick={() => setBilling(o.id)}
                    className={`relative inline-flex items-center gap-2 rounded-full px-4 py-1.5 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 dark:focus-visible:ring-offset-slate-900 ${
                      active
                        ? "bg-white text-slate-900 shadow-sm dark:bg-slate-700 dark:text-white"
                        : "text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
                    }`}
                  >
                    {o.label}
                    {o.id === "annual" && (
                      <span className="rounded-full bg-emerald-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300">
                        -20%
                      </span>
                    )}
                  </button>
                );
              })}
            </div>
            <p className="text-xs text-slate-500 dark:text-slate-500">
              14-day free trial on every plan. No credit card required.
            </p>
          </div>
        </motion.div>

        {/* Matrix */}
        <motion.div
          className="mt-12 rounded-3xl border border-slate-200 bg-white/70 p-2 shadow-sm backdrop-blur-sm sm:mt-14 sm:p-3 dark:border-slate-800 dark:bg-slate-900/40"
          {...enter}
          transition={{ duration: 0.5, ease: "easeOut", delay: reduce ? 0 : 0.1 }}
        >
          <div className="overflow-x-auto">
            <table className="w-full min-w-[720px] border-collapse text-left">
              <caption className="sr-only">Feature comparison across the Starter, Growth, and Scale plans</caption>
              <thead>
                <tr>
                  <th scope="col" className="w-2/5 px-3 pb-4 pt-3 text-left align-bottom sm:px-4">
                    <p className="text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500">
                      Compare plans
                    </p>
                  </th>
                  {PLANS.map((p) => {
                    const price = billing === "monthly" ? p.monthly : p.annual;
                    const note = billing === "annual" ? `Billed $${p.annual * 12}/yr` : "Billed monthly";
                    return (
                      <th key={p.id} scope="col" className="px-2 pb-4 pt-3 align-bottom sm:px-3">
                        <div
                          className={`relative flex h-full flex-col gap-3 rounded-2xl p-4 text-left ${
                            p.featured
                              ? "bg-indigo-50/70 ring-1 ring-inset ring-indigo-500/40 dark:bg-indigo-500/10 dark:ring-indigo-400/30"
                              : ""
                          }`}
                        >
                          {p.featured && (
                            <span className="relative -mt-1 mb-0.5 inline-flex w-fit items-center gap-1 overflow-hidden rounded-full bg-indigo-600 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide text-white">
                              <span className="pxfm-shine pointer-events-none absolute inset-0" aria-hidden="true" />
                              <span className="relative">Most popular</span>
                            </span>
                          )}
                          <div>
                            <h3 className="text-sm font-semibold text-slate-900 dark:text-white">{p.name}</h3>
                            <p className="mt-1 text-xs font-normal leading-relaxed text-slate-500 dark:text-slate-400">
                              {p.tagline}
                            </p>
                          </div>
                          <div className="mt-auto">
                            <div className="flex items-baseline gap-1">
                              <motion.span
                                key={`${p.id}-${billing}`}
                                initial={reduce ? false : { opacity: 0, y: -6 }}
                                animate={reduce ? {} : { opacity: 1, y: 0 }}
                                transition={{ duration: 0.25, ease: "easeOut" }}
                                className="text-2xl font-bold tracking-tight text-slate-900 dark:text-white"
                              >
                                ${price}
                              </motion.span>
                              <span className="text-xs font-medium text-slate-500 dark:text-slate-400">/mo</span>
                            </div>
                            <p className="mt-1 text-[11px] font-normal text-slate-400 dark:text-slate-500">{note}</p>
                          </div>
                          <button
                            type="button"
                            className={`inline-flex w-full items-center justify-center gap-1.5 rounded-xl px-4 py-2.5 text-sm font-semibold transition 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-slate-950 ${
                              p.featured
                                ? "bg-indigo-600 text-white shadow-sm hover:bg-indigo-500"
                                : "border border-slate-300 bg-white text-slate-800 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
                            }`}
                          >
                            {p.cta}
                            <ArrowIcon />
                          </button>
                        </div>
                      </th>
                    );
                  })}
                </tr>
              </thead>

              {GROUPS.map((group) => {
                const isOpen = open[group.id];
                const panelId = `${uid}-${group.id}`;
                return (
                  <tbody key={group.id} id={panelId}>
                    <tr>
                      <th scope="colgroup" colSpan={4} className="p-0">
                        <button
                          type="button"
                          aria-expanded={isOpen}
                          aria-controls={panelId}
                          onClick={() => toggleGroup(group.id)}
                          className="flex w-full items-center gap-2 border-y border-slate-200 bg-slate-50 px-3 py-3 text-left text-sm font-semibold text-slate-800 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 sm:px-4 dark:border-slate-800 dark:bg-slate-900/60 dark:text-slate-100 dark:hover:bg-slate-800/60"
                        >
                          <ChevronIcon open={isOpen} />
                          <span>{group.name}</span>
                          <span className="ml-1 rounded-full bg-slate-200 px-1.5 py-0.5 text-[10px] font-semibold text-slate-500 dark:bg-slate-700 dark:text-slate-300">
                            {group.rows.length}
                          </span>
                        </button>
                      </th>
                    </tr>

                    {isOpen &&
                      group.rows.map((row) => (
                        <tr
                          key={row.label}
                          className="border-b border-slate-100 transition-colors last:border-b-0 hover:bg-slate-50/70 dark:border-slate-800/70 dark:hover:bg-slate-800/30"
                        >
                          <th scope="row" className="px-3 py-3.5 text-left align-top text-sm font-medium text-slate-700 sm:px-4 dark:text-slate-300">
                            <span>{row.label}</span>
                            {row.hint && (
                              <span className="mt-0.5 block text-xs font-normal text-slate-400 dark:text-slate-500">
                                {row.hint}
                              </span>
                            )}
                          </th>
                          {PLANS.map((p) => {
                            const v = row.values[p.id];
                            return (
                              <td
                                key={p.id}
                                className={`px-2 py-3.5 text-center align-middle text-sm sm:px-3 ${
                                  p.featured ? "bg-indigo-50/50 dark:bg-indigo-500/10" : ""
                                }`}
                              >
                                {typeof v === "string" ? (
                                  <span className="font-medium text-slate-700 dark:text-slate-200">{v}</span>
                                ) : v ? (
                                  <span className="inline-flex items-center justify-center rounded-full bg-emerald-100 p-1 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-400">
                                    <CheckIcon />
                                    <span className="sr-only">Included</span>
                                  </span>
                                ) : (
                                  <span className="inline-flex items-center justify-center text-slate-300 dark:text-slate-600">
                                    <MinusIcon />
                                    <span className="sr-only">Not included</span>
                                  </span>
                                )}
                              </td>
                            );
                          })}
                        </tr>
                      ))}
                  </tbody>
                );
              })}
            </table>
          </div>
        </motion.div>

        <p className="mt-6 text-center text-xs text-slate-500 dark:text-slate-500">
          Prices in USD, exclusive of local tax. Annual plans are billed once per year. Need something custom?{" "}
          <a
            href="#contact"
            className="font-medium text-indigo-600 underline-offset-2 hover:underline dark:text-indigo-400"
          >
            Contact our team
          </a>
          .
        </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 →
Simple Pricing

Simple Pricing

Original

A three-tier pricing section with a highlighted popular plan.

Single Plan Pricing Card

Single Plan Pricing Card

Original

A focused one-plan pricing card with price, trial badge, feature list, call to action and a money-back trust note, ideal when you sell a single flat-rate product.

Three Tier Pricing With Monthly Annual Toggle

Three Tier Pricing With Monthly Annual Toggle

Original

A three-tier pricing grid with a pure-CSS monthly and annual billing toggle that swaps every price with no JavaScript, using a native checkbox and Tailwind group-has state.

Plan Feature Comparison Table

Plan Feature Comparison Table

Original

A responsive, semantic feature comparison table across three plans with grouped rows, tick and dash indicators and screen-reader labels so visitors can weigh every feature side by side.

Staggered Glow Pricing Tiers

Staggered Glow Pricing Tiers

Original

A three-tier pricing section whose cards reveal in a staggered spring cascade, with a rotating conic-gradient glow on the popular plan and prices that count up smoothly when you toggle monthly and annual billing.

Spotlight Cursor Pricing Cards

Spotlight Cursor Pricing Cards

Original

Pricing cards that light up with a cursor-following radial spotlight, drift over floating background orbs and rise into view with a staggered spring entrance, plus a pulsing glow on the popular plan.

Highlight Follow Pricing Plans

Highlight Follow Pricing Plans

Original

A pricing row where an animated gradient highlight glides via shared layout animation to whichever plan you hover, a three-way billing switch slides its pill, prices recount instantly and a perks marquee scrolls beneath.

Three Tier Pricing

Three Tier Pricing

Original

three-tier pricing cards

Toggle Annual Pricing

Toggle Annual Pricing

Original

pricing with monthly/annual toggle

Single Highlight Pricing

Single Highlight Pricing

Original

single highlighted plan

Comparison Table Pricing

Comparison Table Pricing

Original

pricing comparison table

Cards Gradient Pricing

Cards Gradient Pricing

Original

gradient pricing cards