Créer une section compteurs animés et timeline de jalons en React
Un compteur animé en React déclenche une boucle setInterval dès que l'élément entre dans le viewport, détecté par le hook useInView de Framer Motion, en incrémentant un state local jusqu'à atteindre la valeur cible. Combine-le avec une timeline de jalons alternée en CSS Grid pour obtenir une section about complète.
- Stack : React 18 + Framer Motion 11 + Tailwind v4, ~95 lignes, zéro dépendance supplémentaire.
- Déclencheur d'animation : useInView de Framer Motion avec once:true, le compteur ne se déclenche qu'une seule fois à l'entrée dans le viewport.
- Le pas du compteur est adaptatif : Math.floor(value / 60) garantit que tous les compteurs finissent en environ le même temps quelle que soit leur magnitude.
- Accessible : les compteurs utilisent tabular-nums pour une largeur stable ; tout le texte est du vrai DOM, pas du canvas.
- La timeline alterne gauche/droite sur md+ avec l'ordre de colonnes CSS Grid ; elle se réduit à une colonne alignée à gauche sur mobile.
About Counter Milestones réunit deux patterns classiques de page about en une section cohérente : une rangée de chiffres qui s'animent au scroll, et une timeline verticale de jalons présentés en grille alternée sur deux colonnes. Le résultat communique la crédibilité par les données tout en racontant l'histoire de la marque de façon chronologique.
Anatomie
La section contient trois blocs empilés dans un conteneur max-w-6xl. En haut, un header centré avec un badge optionnel en span, un h2 et un paragraphe de sous-titre, le tout avec un fade-in via un seul motion.div whileInView. En dessous, une grille 2 colonnes (mobile) à 4 colonnes (desktop) affiche un AnimatedCounter par donnée. Le dernier bloc est la timeline de jalons : un conteneur relative portant une ligne verticale de 1px centrée en absolu sur md et décalée à gauche sur mobile, avec les cartes qui entrent en fondu en cascade via whileInView sur chaque élément.
Comment ça marche
Chaque AnimatedCounter est un composant isolé qui attache une ref à son div wrapper et la passe à useInView. Quand isInView passe à true, un useEffect lance un setInterval qui incrémente un state local display de Math.max(1, Math.floor(value / 60)) toutes les 20ms. Quand display atteint la valeur cible, l'intervalle est arrêté. La fonction de cleanup du useEffect garantit que l'intervalle est toujours annulé si le composant se démonte. Les entrées de la timeline utilisent whileInView avec un délai de i * 0.1s pour décaler les fondus sans orchestration manuelle.
Comment le coder en React
Construis le sous-composant AnimatedCounter
Crée un composant qui accepte value, suffix et label. Attache une ref au div externe, passe-la à useInView avec once:true, puis démarre l'intervalle dans un useEffect qui dépend de [isInView, value]. Retourne toujours le cleanup pour éviter les fuites mémoire.
const ref = useRef<HTMLDivElement>(null); const isInView = useInView(ref, { once: true }); const [display, setDisplay] = useState(0); useEffect(() => { if (!isInView) return; const step = Math.max(1, Math.floor(value / 60)); const timer = setInterval(() => { setDisplay(prev => { if (prev + step >= value) { clearInterval(timer); return value; } return prev + step; }); }, 20); return () => clearInterval(timer); }, [isInView, value]);Dispose la grille de compteurs
Enveloppe les compteurs dans un CSS Grid avec grid-cols-2 sur mobile et grid-cols-4 sur md. Mappe sur ton tableau counters et rends un AnimatedCounter par entrée. Ajoute tabular-nums au paragraphe du nombre pour que les chiffres ne changent pas de largeur pendant le décompte.
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 mb-20"> {counters.map((c, i) => <AnimatedCounter key={i} {...c} />)} </div>Construis la timeline de jalons alternée
Place un conteneur relative autour de ta liste de jalons. À l'intérieur, rends un div de 1px en absolu comme ligne verticale, centré avec left-1/2 sur desktop. Chaque jalon est une grille deux colonnes ; les indices pairs mettent le texte à gauche (text-right, pr-12), les impairs le basculent en colonne droite avec order-2. Le point connecteur est en absolu, left-1/2, avec une bordure en couleur accent.
<div className="relative"> <div className="absolute left-4 md:left-1/2 top-0 bottom-0 w-px" style={{ backgroundColor: "var(--color-border)" }} /> {milestones.map((ms, i) => ( <motion.div key={i} initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true, margin: "-60px" }} transition={{ delay: i * 0.1, duration: 0.5 }} className="relative grid grid-cols-1 md:grid-cols-2 gap-8" > <div className={i % 2 === 0 ? "md:text-right md:pr-12" : "md:order-2 md:pl-12"}> <span className="text-xs font-bold">{ms.year}</span> <h3 className="mt-1 text-lg font-semibold">{ms.title}</h3> </div> </motion.div> ))} </div>Stylise avec des tokens CSS, pas de couleurs en dur
Chaque référence de couleur dans le composant utilise une propriété CSS personnalisée : --color-accent pour les chiffres et les étiquettes d'année, --color-foreground pour les titres, --color-foreground-muted pour le corps de texte, et --color-border pour la ligne de timeline et le bord du point connecteur. La section fonctionne ainsi avec les 7 presets de thème sans modifier une seule prop.
Quand l'utiliser
Utilise cette section sur les pages about d'entreprises, les sites de produits SaaS et les portfolios d'agences où la preuve sociale par les chiffres est importante. Elle fonctionne bien au milieu d'une page longue, placée après l'intro équipe et avant le CTA. Évite-la si tu as moins de trois données significatives, une grille de compteurs à moitié vide paraît creuse. Sur les sites avec un historique court, remplace les jalons par des sorties produit ou des lancement de fonctionnalités pour densifier la timeline.
Utilisé par
- Linear, Utilise des compteurs de métriques et une timeline de jalons produit sur sa page about pour ancrer la marque dans une traction mesurable.
- Stripe, Présente des chiffres de croissance animés aux côtés d'une timeline d'histoire de l'entreprise, combinant signaux de confiance et récit en un seul scroll.
- Vercel, Associe des statistiques de déploiement clés à des jalons produit chronologiques pour montrer l'échelle et la dynamique sur la même page.
- Notion, Combine des jalons de nombre d'utilisateurs avec une section d'histoire de l'entreprise, en utilisant des compteurs animés comme signal de confiance principal.
FAQ
Pourquoi utiliser setInterval plutôt que l'animation Framer Motion pour le compteur ?
setInterval donne un contrôle direct sur le state d'affichage numérique, ce qui facilite le rendu de l'entier exact à chaque frame, l'ajout d'un suffixe et l'arrêt précis à la valeur cible. L'API animate de Framer Motion est mieux adaptée aux transformations DOM et à l'opacité ; piloter un compteur React manuellement via un intervalle garde la logique simple.
Comment faire finir tous les compteurs en même temps ?
La formule de pas Math.max(1, Math.floor(value / 60)) adapte l'incrément à la valeur cible, donc un compteur allant à 10000 avance de ~166 par tick pendant qu'un allant à 50 avance de 1 par tick, les deux atteignant leur cible en environ 60 ticks à 20ms chacun, soit environ 1,2 seconde au total.
Puis-je ajouter une icône ou une illustration à chaque jalon ?
Oui. Le div du point connecteur (absolu, left-1/2) peut être remplacé par un cercle plus grand contenant une icône de lucide-react. Garde-le suffisamment petit (32-40px) pour ne pas décaler les colonnes de texte, le point w-3 h-3 est déjà positionné en absolu hors du flux.
Le compteur se réanime-t-il quand l'utilisateur remonte ?
Non. useInView est appelé avec once:true, donc il se déclenche exactement une fois quand l'élément entre pour la première fois dans le viewport et jamais après. Supprime once:true si tu veux qu'il rejoue à chaque entrée dans le viewport.