Retour au catalogue

Pricing Toggle Morph

Toggle mensuel/annuel avec slider spring physique (useSpring). Prix en crossfade vertical (AnimatePresence popLayout). Badge 'Save X%' pop au passage annuel. Cards 3 colonnes avec layout Framer et CTA whileHover.

pricingcomplex Both Responsive a11y
minimalelegantcorporatesaasagencyuniversalgrid
Theme

Créer un toggle de pricing avec spring animation en React

Un toggle de pricing React avec prix animés utilise useSpring de Framer Motion pour piloter le bouton pill, et AnimatePresence en mode popLayout pour faire glisser le prix verticalement quand on change de période. Toute l'interaction repose sur des CSS custom properties, ce qui le rend compatible avec n'importe quel thème.

  • Stack : React 19 + Framer Motion 11 + lucide-react, ~130 lignes, zéro dépendance supplémentaire.
  • API clés : useSpring, useTransform, AnimatePresence (mode popLayout), motion.div whileHover/whileTap.
  • Le toggle porte aria-pressed et aria-label pour l'accessibilité clavier et lecteurs d'écran.
  • Grille responsive auto-fit qui passe en colonne unique sur écrans étroits.
  • Couleurs entièrement pilotées par CSS custom properties (--color-background, --color-accent, etc.), compatibles avec les 7 presets de thème.

Cette section pricing associe un toggle de facturation à physique spring à un prix qui se transforme entre mensuel et annuel via un crossfade vertical. Plutôt qu'un remplacement brutal, l'ancien prix sort vers le haut pendant que le nouveau entre par le bas. Un badge de réduction apparaît sur le label annuel, animé avec un rebond scale spring. Toute la section est indépendante du thème, pilotée uniquement par des CSS custom properties.

Anatomie

La section comporte trois zones empilées : un header centré (eyebrow, h2, sous-titre) qui entre en fondu au scroll ; une ligne de toggle de facturation avec une pill SpringToggle et un badge de réduction sous AnimatePresence ; une grille CSS de cartes de formule. Chaque carte contient un nom, une description, un bloc AnimatedPrice, un label de facturation, une liste de features avec icônes Check, et un bouton CTA. La formule mise en avant s'affiche avec un schéma de couleurs inversé (fond = foreground) et un léger scale(1.02).

Comment ça marche

Le composant SpringToggle crée un spring Framer Motion (stiffness 500, damping 35) qui mappe 0/1 en offset pixel x pour le thumb via useTransform. L'appel spring.set(yearly ? 1 : 0) à chaque rendu pilote la position du thumb sans useEffect. Le bloc AnimatedPrice enveloppe le prix dans AnimatePresence en mode 'popLayout' : chaque valeur unique obtient son motion.span clé par prix, entrant depuis y:24 et sortant vers y:-24 avec un easing spring de 300ms. Le badge de remise utilise un spring de scale qui rebondit en [0, 1.15, 1] à l'entrée. Les cartes s'animent en decalé via whileInView (délai i * 0.08s).

Comment le coder en React

  1. Construire le toggle spring

    Crée un composant SpringToggle qui reçoit un booléen et un callback. Instancie un spring Framer Motion et mappe sa plage 0/1 vers l'offset pixel où le thumb doit se positionner. Appelle spring.set() directement dans le corps du rendu pour que le thumb suive toujours la prop.

    const spring = useSpring(yearly ? 1 : 0, { stiffness: 500, damping: 35 });
    const x = useTransform(spring, [0, 1], [3, 27]);
    spring.set(yearly ? 1 : 0); // drives the thumb on every render
  2. Animer le prix avec AnimatePresence popLayout

    Enveloppe le span du prix dans AnimatePresence avec mode='popLayout' pour que les éléments sortants soient retirés du flux avant le positionnement des entrants. Clé chaque motion.span par la valeur du prix pour que React traite mensuel et annuel comme des éléments distincts et déclenche les animations d'entrée/sortie.

    <AnimatePresence mode="popLayout">
      <motion.span
        key={price}
        initial={{ y: 24, opacity: 0 }}
        animate={{ y: 0, opacity: 1 }}
        exit={{ y: -24, opacity: 0 }}
        transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
        style={{ display: "block", fontSize: "3rem", fontWeight: 800 }}
      >
        {price}
      </motion.span>
    </AnimatePresence>
  3. Faire apparaître le badge de réduction en rebond scale

    Affiche le label de remise dans AnimatePresence pour qu'il monte et démonte avec l'état yearly. Utilise un tableau animate pour scale, [0, 1.15, 1], pour produire un dépassement naturel sans config spring supplémentaire.

    <AnimatePresence>
      {yearly && (
        <motion.span
          key="badge"
          initial={{ scale: 0, opacity: 0 }}
          animate={{ scale: [0, 1.15, 1], opacity: 1 }}
          exit={{ scale: 0, opacity: 0 }}
          transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
        >
          Save 20%
        </motion.span>
      )}
    </AnimatePresence>
  4. Construire les cartes avec entrée décalée

    Itère sur le tableau tiers et rends chaque carte comme un motion.div avec whileInView. Multiplie l'index par 0.08 pour le délai afin que les cartes cascadent de gauche à droite. Ajoute la prop layout pour que Framer Motion gère les décalages de layout pendant l'animation du prix.

    <motion.div
      key={tier.name}
      layout
      initial={{ opacity: 0, y: 32 }}
      whileInView={{ opacity: 1, y: 0 }}
      viewport={{ once: true }}
      transition={{ duration: 0.55, ease: E, delay: i * 0.08 }}
    >

Quand l'utiliser

Ce pattern est le bon choix pour les produits SaaS qui proposent une facturation mensuelle et annuelle et veulent rendre l'économie tangible sans rechargement de page. Le prix animé attire l'oeil sur la différence de valeur ; le toggle spring paraît premium sans être distrayant. À éviter sur les pages produit e-commerce ou les services à formule unique où un toggle de facturation n'aurait pas de sens. Sur mobile, le thumb spring et l'animation du prix tiennent bien, mais teste l'effondrement de la grille sur viewports étroits pour vérifier la lisibilité.

Utilisé par

  • Linear, Utilise un toggle mensuel/annuel avec mise à jour instantanée du prix et un label d'économie visible sur le plan annuel.
  • Vercel, Basculement de période de facturation qui met les prix à jour en ligne, avec un label de réduction annuelle clair sur chaque formule.
  • Lemon Squeezy, Toggle animé entre mensuel et annuel, badge de remise sur le label annuel, correspondant exactement à ce pattern.
  • Supabase, Bascule mensuel/annuel qui met les prix à jour par formule et met en avant les économies sur le plan annuel.

FAQ

Pourquoi utiliser AnimatePresence popLayout plutôt que simplement transitionner l'opacité ?

popLayout retire l'élément sortant du flux de layout immédiatement, pour que le prix entrant prenne sa bonne position sans chevauchement. Un simple cross-fade d'opacité afficherait les deux chiffres superposés pendant la transition.

Comment ajouter une quatrième formule sans casser la grille ?

La grille utilise repeat(auto-fit, minmax(280px, 1fr)), donc une quatrième formule s'insère naturellement dans la même ligne sur grands écrans ou passe à la ligne suivante sur les plus étroits. Augmente la contrainte maxWidth du conteneur de grille si tu veux les quatre cartes côte à côte.

Puis-je piloter le toggle avec un paramètre d'URL pour que la facturation annuelle persiste à la navigation ?

Oui. Remplace le useState par une valeur lue depuis searchParams et mets à jour l'URL au toggle au lieu d'un état local. L'animation se déclenche quand même car le spring se pilote depuis la prop, pas depuis un état interne.

Le spring du prix lag sur les Android bas de gamme. Comment corriger ça ?

Le composant AnimatedPrice n'utilise que opacity et transform (y), qui sont compositées GPU et ne déclenchent pas de layout. Si tu vois encore du jank, enveloppe le bloc prix dans un conteneur will-change: transform ou réduis le délai de stagger sur les cartes pour réduire le temps total d'animation.

"use client";

import { useState } from "react";
import { motion, AnimatePresence, useSpring, useTransform, type MotionValue } from "framer-motion";
import { Check } from "lucide-react";

interface Tier {
  name: string; description: string; monthlyPrice: number;
  yearlyMonthlyEquiv: number; currency: string;
  features: string[]; ctaLabel: string; highlighted: boolean; badge?: string;
}
interface PricingToggleMorphProps {
  eyebrow?: string; title?: string; subtitle?: string; discountLabel?: string; tiers?: Tier[];
}

const E: [number, number, number, number] = [0.16, 1, 0.3, 1];
const bg = "var(--color-background)";
const fg = "var(--color-foreground)";
const accent = "var(--color-accent)";
const muted = "var(--color-foreground-muted)";
const border = "var(--color-border)";
const card = "var(--color-background-card)";

Code complet réservé à Pro

Code source intégral, export multi-framework et playground.

Passer en Pro, 9,99€/mois

Avis

Toggle de pricing React avec prix animé, Tutoriel