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.
Related on incubator
- Pricing section components: copy-paste pricing tables with billing toggles.
- Stats section components: numeric feature highlights for plan comparison pages.
- Features section components: feature grids that pair naturally with pricing tables.
- Incubator vs Tailwind UI: see how copy-paste sections compare.
- Full component catalog: browse all section types available in the registry.