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 containerrole="row"on each logical rowrole="columnheader"on plan name cellsrole="rowheader"on feature label cellsrole="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.