Créer une section chiffres animés en React avec Framer Motion
Une section chiffres animés en React déclenche une animation de comptage quand la statistique entre dans le viewport, grâce à useInView de Framer Motion pour la détection et une boucle setInterval qui incrémente jusqu'à la valeur finale en ~1500ms. Chaque carte de métrique apparaît en fondu et glisse vers le haut avec un délai échelonné pour que la grille se lise de gauche à droite.
- Stack : React 19 + Framer Motion 11 + Lucide React, ~185 lignes, zéro dépendance supplémentaire.
- Logique compteur : setInterval sur 40 étapes en 1500ms ; useInView avec once:true empêche le re-déclenchement au scroll retour.
- Layout : grille CSS deux colonnes (texte à gauche, cartes métriques 2×2 à droite), se collapse sur mobile.
- Accessible : fontVariantNumeric: tabular-nums évite le layout shift quand les chiffres changent ; le contenu textuel reste lisible sans JS.
- Thème via CSS custom properties uniquement, compatible avec les 7 presets, aucune couleur en dur.
About Numbers Animated est une section React en split-layout qui associe un titre et une description à gauche à une grille de métriques clés à droite. Chaque chiffre compte depuis zéro la première fois qu'il entre dans le viewport, donnant à la page un moment d'élan. Le pattern convient à toute page produit ou entreprise qui doit rendre une échelle concrète sans s'appuyer sur un bloc de texte.
Anatomie
La section externe utilise des CSS custom properties pour le padding et le fond alternatif. En dessous, une grille CSS deux colonnes sépare le contenu éditorial des métriques. La colonne gauche contient un eyebrow labellisé (icône TrendingUp + sous-titre), un h2 et un paragraphe. La colonne droite est sa propre grille 2×2 de cartes ; chaque carte affiche le nombre animé en couleur d'accent en haut et le label de la métrique en dessous, dans une carte bordée et arrondie avec fond card-background.
Comment ça marche
Le sous-composant AnimatedCounter attache un ref à son span et appelle useInView de Framer Motion avec once:true. Quand isInView passe à true, un setInterval se déclenche toutes les 37,5ms (1500ms / 40 étapes) et ajoute value/40 au compteur courant. Math.floor maintient les états intermédiaires comme des entiers, et clearInterval s'arrête exactement à la cible. La section parent anime chaque carte de métrique via whileInView avec un délai échelonné (i * 0.08s) et une courbe d'easing expo-out [0.16, 1, 0.3, 1], pour que les cartes atterrissent avec un léger ressort plutôt qu'un fondu plat.
Comment le coder en React
Crée le sous-composant AnimatedCounter
Extrais la logique de comptage dans un petit sous-composant. Attache un ref au span affiché, puis utilise useInView de Framer Motion pour détecter l'entrée dans le viewport. Lance l'intervalle uniquement quand isInView devient true, et nettoie-le au démontage.
const ref = useRef<HTMLSpanElement>(null); const isInView = useInView(ref, { once: true }); useEffect(() => { if (!isInView) return; const steps = 40; const increment = value / steps; let current = 0; const timer = setInterval(() => { current += increment; if (current >= value) { setCount(value); clearInterval(timer); } else setCount(Math.floor(current)); }, 1500 / steps); return () => clearInterval(timer); }, [isInView, value]);Construis le layout split deux colonnes
Enveloppe la section dans un div conteneur avec une grille CSS deux colonnes égales et un gap de 4rem. Mets ton texte éditorial à gauche et une grille 2×2 imbriquée pour les cartes métriques à droite. Utilise des CSS custom properties pour les espacements pour que la section s'adapte à tous les presets de thème.
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "4rem", alignItems: "center" }}> {/* Left: text */} <motion.div initial={{ opacity: 0, y: 16 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }}> {/* title + description */} </motion.div> {/* Right: 2x2 metrics */} <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1.5rem" }}> {metrics.map((m, i) => ( <motion.div key={i} initial={{ opacity: 0, y: 16 }} whileInView={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.08, ease: [0.16, 1, 0.3, 1] }} viewport={{ once: true }}> <AnimatedCounter value={m.value} suffix={m.suffix} /> <p>{m.label}</p> </motion.div> ))} </div> </div>Style les chiffres avec tabular-nums pour éviter le layout shift
Quand les chiffres changent de 0 vers la cible, les caractères à largeur variable font trembler les cartes. Définis fontVariantNumeric: 'tabular-nums' sur l'élément du nombre pour que chaque chiffre occupe le même espace horizontal tout au long de l'animation.
<p style={{ fontSize: "clamp(2rem, 4vw, 3rem)", fontWeight: 700, color: "var(--color-accent)", fontVariantNumeric: "tabular-nums", }}> <AnimatedCounter value={metric.value} suffix={metric.suffix} /> </p>Passe les métriques en props pour une personnalisation facile
Définis une interface Metric avec value (number), suffix (string, ex. '+' ou '%'), et label. Accepte un prop tableau metrics pour que les consommateurs puissent injecter leurs propres statistiques sans toucher au composant. Les props par défaut de l'implémentation utilisent quatre valeurs illustratives comme fallback.
interface Metric { value: number; suffix: string; label: string; } // Usage <AboutNumbersAnimated sectionTitle="By the numbers" description="We've helped 500+ teams ship faster." metrics={[ { value: 500, suffix: "+", label: "Customers" }, { value: 98, suffix: "%", label: "Satisfaction" }, { value: 40, suffix: "+", label: "Countries" }, { value: 12, suffix: "M", label: "Events/month" }, ]} />
Quand l'utiliser
Utilise cette section quand tu dois traduire l'échelle d'une entreprise en format tangible sur une page About ou Marketing. Elle fonctionne bien en milieu de page après une intro équipe ou une déclaration de mission, pour que les métriques renforcent le récit. Évite-la quand les chiffres ne sont pas encore impressionnants ou quand la page est déjà dense en données ; un bloc de stats creux lit plus mal qu'aucun bloc du tout. Sur mobile la grille deux colonnes s'empile, donc vérifie que le layout empilé reste lisible avant de déployer.
Utilisé par
- Stripe, Utilise des compteurs de stats animés sur sa page About pour montrer le volume de paiements et l'audience développeurs.
- Linear, Affiche les jalons de l'entreprise comme des chiffres déclenchés au scroll sur sa page About.
- Notion, Met en avant le nombre d'utilisateurs et les statistiques de workspace avec des animations count-up pour renforcer la traction.
- Vercel, Des chiffres de déploiements et de développeurs animés apparaissent au scroll dans ses sections marketing et about.
FAQ
Pourquoi utiliser setInterval plutôt que animate() de Framer Motion ?
setInterval donne un contrôle direct sur l'arrondi entier nécessaire pour un affichage propre de nombres entiers. animate() de Framer Motion interpole des flottants, ce qui nécessite un clamping supplémentaire et un output transformer custom. Pour un compteur qui doit afficher des entiers, setInterval avec Math.floor est plus simple et sans surcoût.
Comment déclencher le compteur à chaque passage dans le viewport, pas une seule fois ?
Change once: true en once: false dans l'appel useInView, puis réinitialise aussi setCount(0) au début du useEffect. Cela rejoue l'animation à chaque entrée dans le viewport. Pour la plupart des pages marketing le comportement once:true est préférable, les compteurs répétés pouvant paraître distrayants.
Le layout fonctionne-t-il sur mobile ?
Le composant utilise une grille CSS deux colonnes qui ne se collapse pas automatiquement sur petits écrans. Ajoute une media query ou une grille responsive (gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))') pour empiler les colonnes texte et métriques verticalement sur mobile. La grille 2×2 intérieure peut rester telle quelle ou passer en colonne unique.
Comment ajouter une valeur décimale comme 4,8 étoiles ?
Le type d'état count est déjà number, retire Math.floor de la ligne d'incrément, et utilise count.toFixed(1) dans le rendu. Ajuste l'incrément à value / steps et la condition clearInterval à current >= value comme avant.