Pricing Comparison Table with Plans as Columns

A full feature comparison table. Plans run across the top with their price and button, features run down the side in named groups, and each cell holds a tick, a dash or a short value such as "1 year". One plan column is tinted to mark the recommended choice. On phones there is no sideways scrolling: a three-button switcher appears above the table and shows one plan column at a time next to the feature names.

Code

import { useState } from "react";

const plans = [
  { name: "Basic", price: "$0", note: "Free for 2 agents", cta: "Start free", featured: false },
  { name: "Team", price: "$18", note: "per agent, per month", cta: "Try Team free", featured: true },
  { name: "Business", price: "$42", note: "per agent, per month", cta: "Talk to sales", featured: false },
];

// `values` follows the order of `plans`: text, or true / false for a tick or a dash.
const groups: { title: string; rows: { label: string; values: (string | boolean)[] }[] }[] = [
  {
    title: "Inbox",
    rows: [
      { label: "Shared mailboxes", values: ["1", "5", "Unlimited"] },
      { label: "Saved replies", values: ["10", "Unlimited", "Unlimited"] },
      { label: "Assign and mention teammates", values: [true, true, true] },
      { label: "Rules that sort new mail", values: [false, true, true] },
    ],
  },
  {
    title: "Reporting",
    rows: [
      { label: "Response time report", values: [false, true, true] },
      { label: "Customer satisfaction scores", values: [false, true, true] },
      { label: "Data history", values: ["30 days", "1 year", "Unlimited"] },
    ],
  },
  {
    title: "Security and support",
    rows: [
      { label: "Two-step sign in", values: [true, true, true] },
      { label: "Single sign-on", values: [false, false, true] },
      { label: "Support", values: ["Email", "Email and chat", "Named contact"] },
    ],
  },
];

export default function PricingComparisonTableWithPlanColumns() {
  const [active, setActive] = useState(1);
  const cellHidden = (i: number) => (i === active ? "" : "hidden sm:table-cell");

  return (
    <section className="bg-white px-4 py-16 text-slate-900 sm:px-6 sm:py-20">
      <div className="mx-auto max-w-5xl">
        <h2 className="text-3xl font-semibold tracking-tight sm:text-4xl">Compare plans</h2>
        <p className="mt-3 max-w-xl text-slate-600">Every plan includes unlimited conversations. Pay only for the agents who reply to customers.</p>

        <div className="mt-8 grid grid-cols-3 gap-1 rounded-md bg-slate-100 p-1 sm:hidden" role="group" aria-label="Plan to show">
          {plans.map((plan, i) => (
            <button
              key={plan.name}
              type="button"
              aria-pressed={i === active}
              onClick={() => setActive(i)}
              className={`h-10 rounded text-sm font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-700 ${
                i === active ? "bg-white text-slate-900 shadow-sm" : "text-slate-600"
              }`}
            >
              {plan.name}
            </button>
          ))}
        </div>

        <table className="mt-6 w-full table-fixed border-collapse text-left text-sm sm:mt-10">
          <thead>
            <tr>
              <th scope="col" className="w-1/2 pb-6 pr-4 align-bottom text-xs font-normal leading-relaxed text-slate-600 sm:w-[34%] sm:text-sm">
                <span className="sr-only">Feature. </span>
                Prices in US dollars, billed monthly. Save two months when you pay for a year.
              </th>
              {plans.map((plan, i) => (
                <th
                  key={plan.name}
                  scope="col"
                  className={`border-t-2 px-4 pb-6 pt-5 align-top font-normal sm:px-5 ${cellHidden(i)} ${
                    plan.featured ? "border-sky-600 bg-sky-50" : "border-transparent"
                  }`}
                >
                  <span className="block text-sm font-semibold">{plan.name}</span>
                  <span className="mt-2 block text-3xl font-semibold tracking-tight">{plan.price}</span>
                  <span className="mt-1 block text-xs text-slate-600">{plan.note}</span>
                  <a
                    href="#"
                    className={`mt-4 inline-flex h-10 w-full items-center justify-center rounded-md px-3 text-sm font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-700 focus-visible:ring-offset-2 ${
                      plan.featured ? "bg-slate-900 text-white hover:bg-slate-700" : "border border-slate-300 text-slate-900 hover:bg-slate-50"
                    }`}
                  >
                    {plan.cta}
                  </a>
                </th>
              ))}
            </tr>
          </thead>
          {groups.map((group) => (
            <tbody key={group.title}>
              <tr>
                <th scope="rowgroup" className="border-b border-slate-200 pb-3 pt-8 text-xs font-semibold uppercase tracking-wider text-slate-600">
                  {group.title}
                </th>
                {plans.map((plan, i) => (
                  <td key={plan.name} aria-hidden="true" className={`border-b border-slate-200 ${cellHidden(i)} ${plan.featured ? "bg-sky-50" : ""}`} />
                ))}
              </tr>
              {group.rows.map((row) => (
                <tr key={row.label}>
                  <th scope="row" className="border-b border-slate-200 py-3.5 pr-4 font-normal text-slate-700">
                    {row.label}
                  </th>
                  {row.values.map((value, i) => (
                    <td key={plans[i].name} className={`border-b border-slate-200 px-4 py-3.5 sm:px-5 ${cellHidden(i)} ${plans[i].featured ? "bg-sky-50" : ""}`}>
                      {typeof value === "string" ? (
                        <span className="font-medium text-slate-900">{value}</span>
                      ) : value ? (
                        <>
                          <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="h-5 w-5 text-sky-700">
                            <path d="M4.5 10.5l3.5 3.5 7.5-8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
                          </svg>
                          <span className="sr-only">Included</span>
                        </>
                      ) : (
                        <>
                          <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="h-5 w-5 text-slate-300">
                            <path d="M6 10h8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
                          </svg>
                          <span className="sr-only">Not included</span>
                        </>
                      )}
                    </td>
                  ))}
                </tr>
              ))}
            </tbody>
          ))}
        </table>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/pricing-comparison-table-with-plan-columns.json

When to use it

How to customize it

Accessibility

Questions

How do I make a pricing table responsive without horizontal scroll?

Show one plan at a time on small screens. This component keeps a single table and adds `hidden sm:table-cell` to the columns that are not selected, with a row of buttons to switch between them. From 640px up every column is visible.

Should I use a table or cards for pricing?

Use cards when each plan can be summed up in four or five points. Use a table when there are ten or more features and people need to compare the same row across plans. Many pricing pages use cards first and a table below.

How many rows is too many?

Group them and keep each group under six or seven rows. Leave out features that every plan shares unless they answer a common question, because rows of identical ticks add length without helping anyone choose.

Can I keep the header row visible while scrolling?

Yes. Add `sticky top-0 z-10` and a solid background such as `bg-white` to each header cell. Keep the tinted background on the featured column so the highlight still lines up.

Does it need JavaScript?

Only for the phone switcher. On wider screens the table is plain HTML and CSS, so the HTML version works as it is on tablets and desktops.

Related: Pricing Table with Monthly and Yearly Toggle, Two-Plan Pricing with Comparison Lists, Single Price Card with Included List and Guarantee.

More pricing or all free UI components. Start for free to have a full site built around it.

More in Pricing

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap