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.