Les pages de tarification convertissent ou pas. Et le composant qui porte le plus de poids sur toute page de pricing SaaS est le tableau comparatif — l'endroit où les prospects comparent les plans côte à côte et décident de passer à l'offre supérieure. Un tableau mal construit fait perdre des ventes. Un tableau clair, scannable et mobile-friendly les conclut. Ce guide couvre la construction de tableaux comparatifs production-ready en React, du grid basique aux matrices de fonctionnalités avec toggle de facturation.
Le grid comparatif de base
Commencez par la structure la plus simple possible : un grid où chaque colonne est un plan et chaque ligne une fonctionnalité. Les props TypeScript gardent la couche de données propre :
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";
}
Le tableau features définit l'ordre des lignes. Le Record de chaque plan mappe les noms de fonctionnalités vers un booléen (checkmark ou tiret) ou une valeur textuelle ("10 Go", "Illimité"). Cette approche est assez flexible pour couvrir la plupart des structures de pricing SaaS sans sur-ingénierie des types.
Matrice de fonctionnalités avec checkmarks
La matrice de fonctionnalités est le cœur de toute page de comparaison. Rendez-la en CSS Grid plutôt qu'en <table> HTML — les grids offrent bien plus de contrôle sur le comportement responsive :
export default function ComparisonTable({ plans, features, billingCycle }: ComparisonTableProps) {
const columns = plans.length + 1; // +1 pour la colonne des labels
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",
}}
>
{/* En-tête */}
<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)" }}>
/mois
</span>
</p>
</div>
))}
{/* Lignes de fonctionnalités */}
{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>
);
}
Le flag plan.highlighted ajoute un fond accent subtil à la colonne du plan recommandé. Ce poids visuel attire l'œil naturellement — pas besoin d'un badge "Le plus populaire" (même si vous pouvez en ajouter un).
Toggle facturation : annuel vs mensuel
Le toggle de facturation est un composant contrôlé qui se place au-dessus du tableau. Gardez l'état dans le parent et passez billingCycle en prop :
"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" ? "Mensuel" : "Annuel (-20%)"}
</button>
))}
</div>
<ComparisonTable plans={plans} features={features} billingCycle={billing} />
</section>
);
}
Conseil : affichez toujours le mode "annual" par défaut. Les plans annuels ont un LTV plus élevé et la plupart des entreprises SaaS veulent orienter les utilisateurs vers cette option. Le label du toggle doit mentionner explicitement les économies — "Annuel (-20%)" convertit mieux que simplement "Annuel".
Mettre en avant le plan recommandé
Au-delà de la couleur de fond subtile, vous pouvez ajouter un accent en haut et un 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",
}}
/>
)}
Cette barre d'accent de 3px est visible sans être intrusive. Combinez-la avec un petit badge "Recommandé" ou "Le plus populaire" et la colonne mise en avant devient le choix par défaut évident.
Tableaux responsive sur mobile
Les tableaux comparatifs sont notoirement difficiles sur petit écran. Un grid à 4 colonnes ne tient pas dans un viewport de 375px. Deux approches fonctionnent bien :
Défilement horizontal — enveloppez le grid dans un conteneur avec overflow-x: auto et une largeur minimale sur le grid lui-même. Ajoutez un dégradé subtil sur le bord droit pour signaler la possibilité de scroller :
<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>
Cartes empilées — sur mobile, passez du grid à des cartes de plans empilées où chaque carte liste ses propres fonctionnalités. Utilisez une media query ou un hook useMediaQuery pour changer de layout. Cette approche demande plus de travail mais produit une meilleure expérience mobile pour les pages avec 4+ plans.
Accessibilité
Les tableaux comparatifs doivent être navigables par les lecteurs d'écran. Si vous utilisez CSS Grid au lieu de <table>, ajoutez les rôles ARIA :
role="table"sur le conteneur du gridrole="row"sur chaque ligne logiquerole="columnheader"sur les cellules de noms de plansrole="rowheader"sur les cellules de labels de fonctionnalitésrole="cell"sur les cellules de valeurs
Les icônes checkmark nécessitent aria-label="Inclus" et les tirets aria-label="Non inclus". Sans ces attributs, les utilisateurs de lecteurs d'écran n'entendent rien pour ces cellules.
Sections de comparaison prêtes à l'emploi
Construire un tableau comparatif qui gère les toggles de facturation, les plans mis en avant, les layouts responsive et l'accessibilité correctement représente un travail non trivial. Le catalogue pricing d'Incubator inclut plusieurs variantes de tableaux comparatifs — cartes côte à côte, matrices de fonctionnalités complètes et layouts avec toggle — tous construits avec le système de tokens CSS et les props TypeScript décrits dans ce guide. Si vous avez besoin d'une page de comparaison dédiée, consultez le catalogue de sections de comparaison pour des layouts pleine page avec regroupement par catégorie et lignes repliables. Chaque variante est prête à copier-coller dans votre projet Next.js.