Back to blog

React Comparison Tables for SaaS Pricing Pages

Published on March 20, 2026·6 min read

Pricing pages convert or they don't. And the component that carries the most weight on any SaaS pricing page is the comparison table — the place where prospects stack plans side by side and decide whether to upgrade. A poorly built table loses deals. A clear, scannable, mobile-friendly one closes them. This guide covers how to build production-grade comparison tables in React, from basic grids to full feature matrices with billing toggles.

The Basic Comparison Grid

Start with the simplest possible structure: a grid where each column is a plan and each row is a feature. TypeScript props keep the data layer clean:

interface Plan {
  name: string;
  price: { monthly: number; annual: number };
  features: Record<string, boolean | string>;
  highlighted?: boolean;
}

interface ComparisonTableProps {
  plans: Plan[];
  features: string[];
  billingCycle: "monthly" | "annual";
}

The features array defines row order. Each plan's features record maps feature names to either a boolean (checkmark or dash) or a string value ("10 GB", "Unlimited"). This approach is flexible enough to handle most SaaS pricing structures without over-engineering the types.

Feature Matrix with Checkmarks

The feature matrix is the core of any comparison page. Render it as a CSS Grid rather than an HTML <table> — grids give you far more control over responsive behavior:

export default function ComparisonTable({ plans, features, billingCycle }: ComparisonTableProps) {
  const columns = plans.length + 1; // +1 for feature label column

  return (
    <div
      style={{
        display: "grid",
        gridTemplateColumns: `minmax(200px, 1.5fr) repeat(${plans.length}, 1fr)`,
        gap: 0,
        width: "100%",
        maxWidth: "var(--container-max-width)",
        margin: "0 auto",
      }}
    >
      {/* Header row */}
      <div style={{ padding: "1.5rem 1rem" }} />
      {plans.map((plan) => (
        <div
          key={plan.name}
          style={{
            padding: "1.5rem 1rem",
            textAlign: "center",
            background: plan.highlighted ? "var(--color-accent-subtle)" : "transparent",
            borderRadius: plan.highlighted ? "var(--radius-lg) var(--radius-lg) 0 0" : 0,
          }}
        >
          <h3 style={{ fontSize: "1.25rem", fontWeight: 700, color: "var(--color-foreground)" }}>
            {plan.name}
          </h3>
          <p style={{ fontSize: "2rem", fontWeight: 700, marginTop: "0.5rem" }}>
            ${billingCycle === "monthly" ? plan.price.monthly : plan.price.annual}
            <span style={{ fontSize: "0.875rem", fontWeight: 400, color: "var(--color-foreground-muted)" }}>
              /mo
            </span>
          </p>
        </div>
      ))}

      {/* Feature rows */}
      {features.map((feature, i) => (
        <>
          <div
            key={`label-${feature}`}
            style={{
              padding: "1rem",
              borderTop: "1px solid var(--color-border)",
              color: "var(--color-foreground-muted)",
              fontSize: "0.9375rem",
              display: "flex",
              alignItems: "center",
            }}
          >
            {feature}
          </div>
          {plans.map((plan) => {
            const value = plan.features[feature];
            return (
              <div
                key={`${plan.name}-${feature}`}
                style={{
                  padding: "1rem",
                  borderTop: "1px solid var(--color-border)",
                  textAlign: "center",
                  display: "flex",
                  alignItems: "center",
                  justifyContent: "center",
                  background: plan.highlighted ? "var(--color-accent-subtle)" : "transparent",
                }}
              >
                {value === true && <CheckIcon />}
                {value === false && <span style={{ color: "var(--color-foreground-muted)" }}>—</span>}
                {typeof value === "string" && (
                  <span style={{ fontWeight: 500 }}>{value}</span>
                )}
              </div>
            );
          })}
        </>
      ))}
    </div>
  );
}

The plan.highlighted flag adds a subtle accent background to the recommended plan column. This visual weight draws the eye naturally — no "Most Popular" badge required (though you can add one).

Billing Toggle: Annual vs Monthly

The billing toggle is a controlled component that sits above the table. Keep state in the parent and pass billingCycle down:

"use client";

import { useState } from "react";

export default function PricingSection() {
  const [billing, setBilling] = useState<"monthly" | "annual">("annual");

  return (
    <section style={{ padding: "var(--section-padding-y-lg) 0" }}>
      <div style={{ display: "flex", justifyContent: "center", gap: "0.5rem", marginBottom: "3rem" }}>
        {(["monthly", "annual"] as const).map((cycle) => (
          <button
            key={cycle}
            onClick={() => setBilling(cycle)}
            style={{
              padding: "0.625rem 1.5rem",
              borderRadius: "var(--radius-full)",
              border: "1px solid var(--color-border)",
              background: billing === cycle ? "var(--color-accent)" : "transparent",
              color: billing === cycle ? "#fff" : "var(--color-foreground-muted)",
              fontWeight: 500,
              fontSize: "0.875rem",
              cursor: "pointer",
              transition: "all var(--duration-normal) var(--ease-out)",
            }}
          >
            {cycle === "monthly" ? "Monthly" : "Annual (save 20%)"}
          </button>
        ))}
      </div>

      <ComparisonTable plans={plans} features={features} billingCycle={billing} />
    </section>
  );
}

Tip: always default to "annual". Annual plans have higher LTV and most SaaS companies want to nudge users toward them. The toggle label should call out the savings explicitly — "Annual (save 20%)" converts better than just "Annual".

Highlighting the Recommended Plan

Beyond the subtle background color, you can add a top border accent and a badge:

{plan.highlighted && (
  <div
    style={{
      position: "absolute",
      top: "-1px",
      left: 0,
      right: 0,
      height: "3px",
      background: "var(--color-accent)",
      borderRadius: "var(--radius-lg) var(--radius-lg) 0 0",
    }}
  />
)}

This 3px accent bar is visible without being intrusive. Pair it with a small pill badge saying "Recommended" or "Most Popular" and the highlighted column becomes the obvious default choice.

Mobile-Responsive Tables

Comparison tables are notoriously difficult on small screens. A 4-column grid does not fit on a 375px viewport. Two approaches work well:

Horizontal scroll — wrap the grid in a container with overflow-x: auto and a minimum width on the grid itself. Add a subtle gradient fade on the right edge to signal scrollability:

<div style={{ position: "relative", overflow: "hidden" }}>
  <div style={{ overflowX: "auto", WebkitOverflowScrolling: "touch" }}>
    <div style={{ minWidth: "700px" }}>
      <ComparisonTable {...props} />
    </div>
  </div>
  <div
    aria-hidden
    style={{
      position: "absolute",
      top: 0,
      right: 0,
      bottom: 0,
      width: "40px",
      background: "linear-gradient(to right, transparent, var(--color-background))",
      pointerEvents: "none",
    }}
  />
</div>

Stacked cards — on mobile, switch from a grid to stacked plan cards where each card lists its own features. Use a media query or a useMediaQuery hook to swap layouts. This approach is more work but produces a better mobile experience for pages with 4+ plans.

Accessibility

Comparison tables must be navigable by screen readers. If you use CSS Grid instead of <table>, add ARIA roles:

  • role="table" on the grid container
  • role="row" on each logical row
  • role="columnheader" on plan name cells
  • role="rowheader" on feature label cells
  • role="cell" on value cells

Checkmark icons need aria-label="Included" and dash icons need aria-label="Not included". Without these, screen reader users hear nothing for those cells.

Production-Ready Comparison Sections

Building a comparison table that handles billing toggles, highlighted plans, responsive layouts, and accessibility correctly is a non-trivial amount of work. The Incubator pricing catalog includes multiple comparison table variants — side-by-side cards, full feature matrices, and toggle-enabled layouts — all built with the CSS token system and TypeScript props described in this guide. If you need a dedicated comparison page, check the comparison section catalog for full-page layouts with category grouping and collapsible rows. Every variant is copy-paste ready for your Next.js project.

VA

Victor Aubague

Developer & creator of Incubator

Full-stack developer specialized in React, Next.js, and TypeScript. I built Incubator to help developers ship beautiful interfaces faster — all components are crafted from real client projects and production code.

LinkedIn
React Comparison Tables for SaaS Pricing Pages — Incubator