Retour au blog

Sections de compteurs animés en React

Publié le 20 mars 2026·7 min read

Les chiffres créent la confiance plus vite que les paragraphes. Quand un visiteur arrive sur votre page et voit "12 000+ clients" ou "99,9% de disponibilité" qui s'incrémentent sous ses yeux, le message passe instantanément. Les sections de compteurs sont parmi les composants à plus fort impact que vous pouvez ajouter à une landing page, et elles sont étonnamment simples à construire en React. Ce guide couvre tout, du compteur basique avec useEffect aux animations Framer Motion déclenchées au scroll.

Compteur basique avec useEffect

Le compteur animé le plus simple utilise useEffect et requestAnimationFrame pour interpoler de 0 à la valeur cible. Aucune dépendance requise :

"use client";

import { useEffect, useRef, useState } from "react";

interface CounterProps {
  target: number;
  duration?: number; // ms
  prefix?: string;
  suffix?: string;
}

export function Counter({ target, duration = 2000, prefix = "", suffix = "" }: CounterProps) {
  const [count, setCount] = useState(0);
  const startTime = useRef<number | null>(null);

  useEffect(() => {
    function animate(timestamp: number) {
      if (!startTime.current) startTime.current = timestamp;
      const elapsed = timestamp - startTime.current;
      const progress = Math.min(elapsed / duration, 1);

      // Ease-out cubic pour une décélération naturelle
      const eased = 1 - Math.pow(1 - progress, 3);
      setCount(Math.round(eased * target));

      if (progress < 1) {
        requestAnimationFrame(animate);
      }
    }

    requestAnimationFrame(animate);
    return () => { startTime.current = null; };
  }, [target, duration]);

  return (
    <span style={{ fontVariantNumeric: "tabular-nums" }}>
      {prefix}{count.toLocaleString("fr-FR")}{suffix}
    </span>
  );
}

La déclaration fontVariantNumeric: "tabular-nums" est critique — sans elle, les chiffres à espacement proportionnel font trembler le nombre horizontalement pendant le décompte. Les chiffres tabulaires garantissent que chaque digit occupe la même largeur.

L'ease-out cubic (1 - Math.pow(1 - progress, 3)) fait démarrer le compteur rapidement et décélérer vers la cible, ce qui paraît naturel. Une interpolation linéaire semble robotique.

Compteur animé avec Framer Motion

Si votre projet utilise déjà Framer Motion, vous pouvez tirer parti de useMotionValue et useTransform pour une approche plus déclarative :

"use client";

import { useEffect } from "react";
import { motion, useMotionValue, useTransform, animate } from "framer-motion";

interface MotionCounterProps {
  target: number;
  duration?: number;
}

export function MotionCounter({ target, duration = 2 }: MotionCounterProps) {
  const motionValue = useMotionValue(0);
  const rounded = useTransform(motionValue, (v) => Math.round(v).toLocaleString("fr-FR"));

  useEffect(() => {
    const controls = animate(motionValue, target, {
      duration,
      ease: [0.16, 1, 0.3, 1],
    });
    return controls.stop;
  }, [motionValue, target, duration]);

  return (
    <motion.span style={{ fontVariantNumeric: "tabular-nums" }}>
      {rounded}
    </motion.span>
  );
}

Cette approche est plus propre car Framer Motion gère la boucle d'animation en interne et le hook useTransform met à jour la valeur affichée de manière réactive. L'ease personnalisé [0.16, 1, 0.3, 1] correspond à la courbe spring-like utilisée dans la plupart des composants Incubator.

Compteur déclenché au scroll

Les compteurs de stats doivent commencer à s'animer quand ils entrent dans le viewport — pas au chargement de la page. Un compteur qui termine son animation avant que l'utilisateur ne l'atteigne gaspille l'effet. Utilisez l'API Intersection Observer :

"use client";

import { useEffect, useRef, useState } from "react";

export function useInView(threshold = 0.3) {
  const ref = useRef<HTMLDivElement>(null);
  const [isInView, setIsInView] = useState(false);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsInView(true);
          observer.disconnect(); // animer une seule fois
        }
      },
      { threshold }
    );

    observer.observe(el);
    return () => observer.disconnect();
  }, [threshold]);

  return { ref, isInView };
}

Puis enveloppez votre compteur :

export function ScrollCounter({ target, ...props }: CounterProps) {
  const { ref, isInView } = useInView();

  return (
    <div ref={ref}>
      {isInView ? <Counter target={target} {...props} /> : <span>0</span>}
    </div>
  );
}

L'observer se déconnecte après la première intersection, donc l'animation ne se déclenche qu'une seule fois. Le threshold: 0.3 signifie que 30% de l'élément doit être visible avant le déclenchement — cela évite que l'animation démarre quand seulement 1px est dans le viewport.

Grille de statistiques avec icônes

Un compteur seul est rarement utile. Le layout standard est un grid à 3 ou 4 colonnes de stats, chacune avec une icône, un nombre et un label :

interface Stat {
  icon: React.ReactNode;
  value: number;
  suffix?: string;
  label: string;
}

interface StatsGridProps {
  stats: Stat[];
}

export function StatsGrid({ stats }: StatsGridProps) {
  const { ref, isInView } = useInView();

  return (
    <section
      ref={ref}
      style={{
        padding: "var(--section-padding-y) 0",
        background: "var(--color-surface)",
      }}
    >
      <div
        style={{
          maxWidth: "var(--container-max-width)",
          margin: "0 auto",
          padding: "0 var(--container-padding-x)",
          display: "grid",
          gridTemplateColumns: `repeat(${Math.min(stats.length, 4)}, 1fr)`,
          gap: "2rem",
        }}
      >
        {stats.map((stat, i) => (
          <div
            key={stat.label}
            style={{
              textAlign: "center",
              padding: "2rem",
            }}
          >
            <div style={{ marginBottom: "1rem", color: "var(--color-accent)" }}>
              {stat.icon}
            </div>
            <div style={{ fontSize: "clamp(2rem, 4vw, 3.5rem)", fontWeight: 700, lineHeight: 1.1 }}>
              {isInView ? (
                <Counter target={stat.value} suffix={stat.suffix} duration={2000 + i * 200} />
              ) : (
                <span>0</span>
              )}
            </div>
            <p style={{
              marginTop: "0.5rem",
              fontSize: "0.9375rem",
              color: "var(--color-foreground-muted)",
            }}>
              {stat.label}
            </p>
          </div>
        ))}
      </div>
    </section>
  );
}

La duration échelonnée (2000 + i * 200) fait que chaque compteur termine légèrement après le précédent, créant un effet de vague à travers le grid. Ce détail subtil rend la section dynamique sans ajouter de complexité.

Pour le responsive, passez à un grid 2 colonnes sur tablette et une seule colonne sur mobile en utilisant clamp() CSS ou une media query sur gridTemplateColumns.

Compteur de preuve sociale

Les compteurs de preuve sociale combinent des statistiques avec des logos de marques ou des avatars. Le pattern est une bande horizontale — souvent placée juste sous le hero — montrant des métriques comme "Utilisé par 4 200+ équipes" aux côtés d'une pile d'avatars :

<div style={{ display: "flex", alignItems: "center", gap: "1rem", justifyContent: "center" }}>
  <div style={{ display: "flex" }}>
    {avatars.map((src, i) => (
      <img
        key={src}
        src={src}
        alt=""
        style={{
          width: 36,
          height: 36,
          borderRadius: "50%",
          border: "2px solid var(--color-background)",
          marginLeft: i > 0 ? "-10px" : 0,
          position: "relative",
          zIndex: avatars.length - i,
        }}
      />
    ))}
  </div>
  <p style={{ fontSize: "0.9375rem", color: "var(--color-foreground-muted)" }}>
    Utilisé par <strong><Counter target={4200} suffix="+" /></strong> équipes
  </p>
</div>

Les avatars superposés (marginLeft négatif) avec un zIndex décroissant créent le classique stack d'avatars. Le border qui correspond à la couleur de fond crée une séparation nette entre les visages.

Considérations de performance

Les compteurs animés déclenchent des re-renders fréquents pendant la phase de comptage. Trois points à garder en tête :

  1. Isolez le composant compteur — gardez le useState à l'intérieur du composant Counter, pas dans le parent. Cela évite que toute la grille de stats se re-rende à chaque frame.
  2. Utilisez requestAnimationFrame — n'utilisez jamais setInterval pour les animations. rAF se synchronise avec le cycle de peinture du navigateur et se met en pause automatiquement dans les onglets en arrière-plan.
  3. Respectez prefers-reduced-motion — les utilisateurs qui ont activé la réduction de mouvement doivent voir le nombre final immédiatement, sans animation.

Sections de statistiques prêtes à l'emploi

Le catalogue stats d'Incubator inclut des grilles de compteurs, des barres de preuve sociale, des bandes de logos avec nombres animés et des variantes compatibles mode sombre — toutes déclenchées au scroll et accessibles par défaut. Pour des patterns de preuve sociale plus larges (murs de témoignages, piles d'avatars, badges de notation), parcourez le catalogue de preuve sociale. Chaque section est livrée avec des props TypeScript et des données mock, prête à 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
Sections de compteurs animés en React — Incubator