Retour au blog

Les meilleurs composants de page de prix React à copier-coller

Publié le 15 mars 2026·6 min read

Les pages de tarification, c'est là que les conversions se jouent. Une section de pricing mal structurée crée de l'hésitation ; bien conçue, elle rend le choix évident. Ce guide couvre les patterns qui font vraiment la différence : la grille à 3 niveaux, le toggle mensuel/annuel, les niveaux mis en avant et les signaux de confiance — le tout implémenté en React avec Tailwind CSS et TypeScript.

Pourquoi trois niveaux

Les recherches en psychologie de l'architecture du choix montrent systématiquement que trois options surpassent deux ou quatre. Deux options imposent un choix binaire qui paraît risqué. Quatre options déclenchent la paralysie d'analyse. Trois options permettent aux visiteurs de se situer — le niveau intermédiaire joue un rôle d'ancrage, rendant le niveau supérieur raisonnable et le niveau inférieur trop limité.

Les noms des niveaux comptent aussi. "Starter / Pro / Enterprise" est galvaudé. Préférez des noms qui reflètent l'identité du client : "Solo / Équipe / Agence", ou "Builder / Studio / Scale". Le nom doit faire penser au visiteur : "c'est pour moi".

Structure des données

Commencez par définir la forme d'un niveau tarifaire en TypeScript :

interface Tier {
  name: string;
  monthlyPrice: number;
  yearlyPrice: number;
  currency: "USD" | "EUR";
  description: string;
  features: string[];
  ctaLabel: string;
  ctaUrl: string;
  highlighted: boolean;
  badge?: string; // e.g. "Most popular"
}

Stocker monthlyPrice et yearlyPrice comme des nombres séparés rend la logique du toggle triviale — pas besoin de calculer des pourcentages au moment du rendu.

Le toggle mensuel/annuel

Le toggle est un composant à deux états avec une animation de pill fluide. Il utilise useState et le motion.div de Framer Motion pour l'indicateur glissant :

"use client";

import { useState } from "react";
import { motion } from "framer-motion";

const ease: [number, number, number, number] = [0.16, 1, 0.3, 1];

function BillingToggle({ onChange }: { onChange: (yearly: boolean) => void }) {
  const [yearly, setYearly] = useState(false);

  function toggle() {
    const next = !yearly;
    setYearly(next);
    onChange(next);
  }

  return (
    <div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
      <span
        style={{
          fontSize: "0.875rem",
          fontWeight: 500,
          color: !yearly ? "var(--color-foreground)" : "var(--color-foreground-muted)",
        }}
      >
        Monthly
      </span>

      <button
        onClick={toggle}
        style={{
          position: "relative",
          width: "3.5rem",
          height: "1.75rem",
          borderRadius: "var(--radius-full)",
          background: yearly ? "var(--color-accent)" : "var(--color-border)",
          border: "none",
          cursor: "pointer",
          transition: `background var(--duration-normal) var(--ease-out)`,
        }}
        aria-label="Toggle billing period"
        aria-pressed={yearly}
      >
        <motion.div
          animate={{ left: yearly ? "calc(100% - 24px)" : "4px" }}
          transition={{ duration: 0.25, ease }}
          style={{
            position: "absolute",
            top: "4px",
            width: "20px",
            height: "20px",
            borderRadius: "50%",
            background: "var(--color-background)",
          }}
        />
      </button>

      <span
        style={{
          fontSize: "0.875rem",
          fontWeight: 500,
          color: yearly ? "var(--color-foreground)" : "var(--color-foreground-muted)",
        }}
      >
        Annual
        <span
          style={{
            marginLeft: "0.5rem",
            padding: "0.125rem 0.5rem",
            borderRadius: "var(--radius-full)",
            background: "var(--color-accent)",
            color: "var(--color-background)",
            fontSize: "0.75rem",
            fontWeight: 600,
            opacity: yearly ? 1 : 0.5,
            transition: `opacity var(--duration-normal) var(--ease-out)`,
          }}
        >
          Save 20%
        </span>
      </span>
    </div>
  );
}

L'attribut aria-pressed rend le toggle accessible aux lecteurs d'écran. L'aria-label fournit le contexte lorsque le bouton n'a pas de libellé textuel visible.

Niveau mis en avant

Le niveau "Le plus populaire" doit se démarquer visuellement de ses voisins. La technique la plus efficace consiste à inverser le schéma de couleurs — la carte mise en avant utilise var(--color-foreground) comme fond et var(--color-background) pour le texte, créant une hiérarchie visuelle naturelle sans couleurs supplémentaires :

function PricingCard({ tier, yearly }: { tier: Tier; yearly: boolean }) {
  const price = yearly ? tier.yearlyPrice : tier.monthlyPrice;

  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        borderRadius: "var(--radius-xl)",
        padding: "2rem",
        background: tier.highlighted
          ? "var(--color-foreground)"
          : "var(--color-background-card)",
        border: tier.highlighted
          ? "none"
          : "1px solid var(--color-border)",
        position: "relative",
      }}
    >
      {tier.badge && tier.highlighted && (
        <span
          style={{
            position: "absolute",
            top: "-0.75rem",
            left: "50%",
            transform: "translateX(-50%)",
            padding: "0.25rem 1rem",
            borderRadius: "var(--radius-full)",
            background: "var(--color-accent)",
            color: "var(--color-foreground)",
            fontSize: "0.75rem",
            fontWeight: 700,
            letterSpacing: "0.05em",
            textTransform: "uppercase",
            whiteSpace: "nowrap",
          }}
        >
          {tier.badge}
        </span>
      )}
      {/* Price, features, CTA */}
    </div>
  );
}

Positionner le badge avec top: -0.75rem et le centrer horizontalement lui donne un effet "flottant" au-dessus de la carte, qui attire naturellement l'œil.

Animation du prix lors du changement

Quand l'utilisateur change de période de facturation, animez la transition du prix pour qu'elle paraisse réactive plutôt que brusque :

import { AnimatePresence, motion } from "framer-motion";

// Inside PricingCard
<AnimatePresence mode="wait">
  <motion.span
    key={price}  // Changing the key triggers exit + enter animation
    initial={{ opacity: 0, y: -10 }}
    animate={{ opacity: 1, y: 0 }}
    exit={{ opacity: 0, y: 10 }}
    transition={{ duration: 0.2 }}
    style={{
      fontSize: "2.5rem",
      fontWeight: 700,
      color: tier.highlighted
        ? "var(--color-background)"
        : "var(--color-foreground)",
    }}
  >
    ${price}
  </motion.span>
</AnimatePresence>

L'astuce key={price} est essentielle — React utilise la clé pour distinguer les éléments, donc la changer indique à AnimatePresence de faire sortir l'ancien prix et entrer le nouveau.

Liste des fonctionnalités

Gardez les descriptions de fonctionnalités courtes et concrètes. "Projets illimités" vaut mieux que "Aucune limite de projets". Utilisez une icône de coche depuis lucide-react :

import { Check } from "lucide-react";

<ul style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
  {tier.features.map((feature) => (
    <li
      key={feature}
      style={{ display: "flex", alignItems: "center", gap: "0.75rem", fontSize: "0.875rem" }}
    >
      <Check
        size={16}
        style={{
          flexShrink: 0,
          color: tier.highlighted
            ? "var(--color-background)"
            : "var(--color-accent)",
        }}
      />
      <span
        style={{
          color: tier.highlighted
            ? "var(--color-background)"
            : "var(--color-foreground)",
          opacity: tier.highlighted ? 0.85 : 1,
        }}
      >
        {feature}
      </span>
    </li>
  ))}
</ul>

Signaux de confiance sous la grille

La grille seule ne suffit pas. Ajoutez une rangée de signaux de confiance directement sous les cartes de pricing. Ces éléments convertissent les indécis :

const trustSignals = [
  "No credit card required",
  "14-day free trial",
  "Cancel anytime",
  "SOC 2 Type II certified",
];

<div
  style={{
    display: "flex",
    flexWrap: "wrap",
    justifyContent: "center",
    gap: "1.5rem",
    marginTop: "2.5rem",
  }}
>
  {trustSignals.map((signal) => (
    <span
      key={signal}
      style={{
        display: "flex",
        alignItems: "center",
        gap: "0.5rem",
        fontSize: "0.8125rem",
        color: "var(--color-foreground-muted)",
      }}
    >
      <Check size={14} style={{ color: "var(--color-accent)" }} />
      {signal}
    </span>
  ))}
</div>

Animer les cartes au scroll en cascade

Utilisez whileInView avec un délai basé sur l'index de la carte pour que les cartes entrent en cascade quand l'utilisateur défile :

<motion.div
  initial={{ opacity: 0, y: 30 }}
  whileInView={{ opacity: 1, y: 0 }}
  transition={{ duration: 0.5, ease, delay: index * 0.1 }}
  viewport={{ once: true }}
>
  <PricingCard tier={tier} yearly={yearly} />
</motion.div>

viewport={{ once: true }} empêche l'animation de se relancer à chaque fois que l'utilisateur repasse devant la section.

Composants de pricing prêts à l'emploi

Si vous souhaitez passer directement à la personnalisation, le catalogue pricing d'Incubator propose plus de 20 variantes de sections tarifaires : layouts avec toggle, comparaisons en matrice de fonctionnalités, sliders de facturation à l'usage, paywalls freemium, cartes spotlight, et bien d'autres — tous construits avec les patterns ci-dessus et prêts à intégrer dans votre projet.

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
Les meilleurs composants de page de prix React à copier-coller — Incubator