Retour au blog

Tableaux comparatifs React pour pages de tarification SaaS

Publié le 20 mars 2026·6 min read

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 grid
  • role="row" sur chaque ligne logique
  • role="columnheader" sur les cellules de noms de plans
  • role="rowheader" sur les cellules de labels de fonctionnalités
  • role="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.

VA

Victor Aubague

Développeur & créateur d'Incubator

Développeur full-stack spécialisé en React, Next.js et TypeScript. J'ai créé Incubator pour aider les développeurs à livrer de belles interfaces plus rapidement — tous les composants sont issus de vrais projets clients et de code en production.

LinkedIn
Tableaux comparatifs React pour pages de tarification SaaS — Incubator