Prérequis
- Niveau : bases JavaScript et manipulation du DOM (cf. manipuler le DOM).
- Outils : VS Code + Live Server, navigateur moderne.
- Temps estimé : 1 h.
Pourquoi un compteur animé ?
Les compteurs animés sont un outil de preuve sociale redoutable : « 500+ clients », « 10 000 projets ». L’œil humain est attiré par le mouvement, ce qui fait stopper le scroll quelques secondes — assez pour ancrer le message. C’est aussi un excellent exercice pour comprendre requestAnimationFrame et l’IntersectionObserver.
Les compteurs animés : effet visuel percutant
Les compteurs animés (count-up) sont ces chiffres qui défilent de 0 jusqu’à la valeur cible. On les voit partout : « 500+ clients », « 10 000 projets livrés ». Voici comment les créer en JavaScript pur, sans bibliothèque.
Version simple : un compteur basique
function animerCompteur(élément, ciblé, duree = 2000) {
let debut = 0;
const increment = ciblé / (duree / 16); // 60 FPS
const timer = setInterval(() => {
debut += increment;
if (debut >= ciblé) {
élément.textContent = ciblé.toLocaleString('fr-FR');
clearInterval(timer);
} else {
élément.textContent = Math.floor(debut).toLocaleString('fr-FR');
}
}, 16);
}
// Utilisation
const el = document.querySelector('.compteur');
animerCompteur(el, 1500, 2000); // Compte jusqu'à 1500 en 2 secondes
Version avancée : avec easing (accélération/décélération)
Pour un effet plus naturel, le compteur accélère puis ralentit :
function compteurAnime(élément, debut, fin, duree) {
const debutTemps = performance.now();
// Fonction easing : ralentit à la fin
function easeOutQuad(t) {
return t * (2 - t);
}
function mettreAJour(tempsActuel) {
const progression = Math.min((tempsActuel - debutTemps) / duree, 1);
const valeur = Math.floor(debut + (fin - debut) * easeOutQuad(progression));
élément.textContent = valeur.toLocaleString('fr-FR');
if (progression < 1) {
requestAnimationFrame(mettreAJour);
}
}
requestAnimationFrame(mettreAJour);
}
// Utilisation
compteurAnime(document.getElementById('clients'), 0, 2500, 2500);
Le HTML et CSS pour l'affichage
<!-- HTML -->
<div class="stats">
<div class="stat">
<span class="compteur" data-cible="1500">0</span>
<span class="suffixe">+</span>
<p>Étudiants formés</p>
</div>
<div class="stat">
<span class="compteur" data-cible="50">0</span>
<p>Formations disponibles</p>
</div>
<div class="stat">
<span class="compteur" data-cible="98">0</span>
<span class="suffixe">%</span>
<p>Taux de satisfaction</p>
</div>
</div>
<style>
.stats { display: flex; justify-content: center; gap: 60px; padding: 40px; }
.stat { text-align: center; }
.compteur { font-size: 48px; font-weight: 700; color: #667eea; display: inline; }
.suffixe { font-size: 48px; font-weight: 700; color: #667eea; }
.stat p { margin-top: 8px; color: #666; font-size: 14px; text-transform: uppercase; }
</style>
Déclencher au scroll (Intersection Observer)
Le compteur ne doit se lancer que quand l'utilisateur le voit :
// Initialiser tous les compteurs au scroll
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const el = entry.target;
const ciblé = parseInt(el.dataset.cible);
compteurAnime(el, 0, ciblé, 2000);
observer.unobserve(el); // Ne lancer qu'une seule fois
}
});
}, { threshold: 0.5 });
// Observer chaque compteur
document.querySelectorAll('.compteur').forEach(el => {
observer.observe(el);
});
💡 Pourquoi Intersection Observer ?
Sans cela, le compteur s'anime dès le chargement de la page, même si l'utilisateur ne le voit pas. Avec l'Observer, l'animation se déclenche uniquement quand la section est visible à l'écran.
Compteur avec formatage avancé
function compteurFormate(élément, ciblé, options = {}) {
const {
duree = 2000,
prefixe = '',
suffixe = '',
decimales = 0,
separateur = ' '
} = options;
const debutTemps = performance.now();
function formater(nombre) {
const fixe = nombre.toFixed(decimales);
const [entier, decimal] = fixe.split('.');
const formate = entier.replace(/\B(?=(\d{3})+(?!\d))/g, separateur);
return prefixe + formate + (decimal ? ',' + decimal : '') + suffixe;
}
function animer(tempsActuel) {
const progression = Math.min((tempsActuel - debutTemps) / duree, 1);
const eased = 1 - Math.pow(1 - progression, 3); // easeOutCubic
const valeur = ciblé * eased;
élément.textContent = formater(valeur);
if (progression < 1) requestAnimationFrame(animer);
}
requestAnimationFrame(animer);
}
// Exemples d'utilisation
compteurFormate(el1, 2500000, { prefixe: '', suffixe: ' FCFA', separateur: ' ' });
// Résultat : "2 500 000 FCFA"
compteurFormate(el2, 99.7, { suffixe: '%', decimales: 1 });
// Résultat : "99,7%"
Code complet prêt à l'emploi
// Copier-coller ce script dans votre site
document.addEventListener('DOMContentLoaded', () => {
const compteurs = document.querySelectorAll('[data-compteur]');
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const el = entry.target;
const ciblé = parseFloat(el.dataset.compteur);
const duree = parseInt(el.dataset.duree) || 2000;
const prefixe = el.dataset.prefixe || '';
const suffixe = el.dataset.suffixe || '';
compteurAnime(el, 0, ciblé, duree);
el.dataset.prefixeVal = prefixe;
el.dataset.suffixeVal = suffixe;
observer.unobserve(el);
}
});
}, { threshold: 0.3 });
compteurs.forEach(el => observer.observe(el));
});
// HTML : <span data-compteur="1500" data-suffixe="+">0</span>
Erreurs fréquentes
Compteur saccadé
Cause : utilisation de setInterval(..., 16) qui n'est pas synchronisé avec l'écran.
Solution : utilisez requestAnimationFrame qui se synchronise avec le rafraîchissement (60-120 Hz selon l'écran).
Animation qui se relance plusieurs fois
Cause : oubli de observer.unobserve(el) après la 1ère animation.
Solution : appelez unobserve dans le callback, ou utilisez l'option { once: true }.
Animation lancée avant que l'utilisateur voie le compteur
Cause : on déclenche au load au lieu d'attendre la visibilité.
Solution : utilisez l'IntersectionObserver avec threshold: 0.3 ou plus.
Ignorer prefers-reduced-motion
Cause : les utilisateurs sensibles au mouvement reçoivent une animation rapide.
Solution : testez matchMedia('(prefers-reduced-motion: reduce)').matches. Si vrai, affichez directement la valeur finale.
Exercice pratique
🎯 Défi : Section statistiques pour votre site
- Créez une section avec 4 compteurs : clients, projets, heures de formation, satisfaction
- Ajoutez l'effet easing pour une animation fluide
- Déclenchez l'animation au scroll avec Intersection Observer
- Formatez les grands nombres avec des espaces (ex: 10 000)
- Bonus : ajoutez une icône au-dessus de chaque compteur
requestAnimationFrame vs setInterval : pourquoi le premier gagne
Beaucoup de tutoriels web montrent encore des compteurs animés avec setInterval(updateCounter, 16) pour viser 60 fps. Cette approche est dépassée en 2026. requestAnimationFrame est l'API standard pour toute animation visuelle, et présente trois avantages décisifs. Premier avantage : synchronisation native avec le rafraîchissement écran du navigateur. Sur un écran 60 Hz, rAF est appelé 60 fois par seconde au moment optimal du cycle de rendu. Sur un écran 120 Hz (iPad Pro, Galaxy Note récent), rAF s'adapte automatiquement à 120 fps.
Deuxième avantage : économie batterie et CPU. Quand l'onglet passe en arrière-plan ou l'écran s'éteint, le navigateur suspend automatiquement les rAF en attente. setInterval continue à tourner, vidant la batterie pour rien. Troisième avantage : pas de drift. setInterval accumule des décalages quand le thread principal est saturé ; rAF donne le timestamp précis depuis le début pour calculer la progression réelle.
function animerCompteur(element, valeurFinale, duree = 2000) {
const debut = performance.now();
function frame(maintenant) {
const progression = Math.min((maintenant - debut) / duree, 1);
const valeur = Math.round(valeurFinale * easeOutQuad(progression));
element.textContent = valeur.toLocaleString('fr-FR');
if (progression < 1) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
function easeOutQuad(t) { return 1 - (1 - t) * (1 - t); }
Cette implémentation tient en 10 lignes, gère naturellement 60/120 fps, et applique une courbe d'accélération easeOutQuad qui rend l'animation plus naturelle qu'une progression linéaire.
Déclencher l'animation au scroll : IntersectionObserver
Un compteur animé n'a d'intérêt que si l'utilisateur le voit. Déclencher l'animation dès le chargement de la page est une erreur — le compteur a déjà fini sa course quand l'utilisateur scroll jusqu'à lui. La solution moderne en 2026 : IntersectionObserver, qui observe quand un élément entre dans le viewport et déclenche l'animation à ce moment précis.
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const el = entry.target;
const valeur = parseInt(el.dataset.valeur, 10);
animerCompteur(el, valeur);
observer.unobserve(el); // Ne pas relancer
}
});
}, { threshold: 0.5 });
document.querySelectorAll('.compteur[data-valeur]').forEach(el => observer.observe(el));
Cette approche scale à des dizaines de compteurs sur la page sans coût mémoire significatif. threshold: 0.5 déclenche l'animation quand 50 % de l'élément est visible, ce qui correspond généralement au moment où l'utilisateur le découvre vraiment.
Easing functions : choisir la bonne courbe
L'animation linéaire (function linear(t) { return t; }) reste rarement la bonne option. Le cerveau humain perçoit comme naturelles les animations qui démarrent rapidement et ralentissent à la fin (ease-out) ou qui démarrent lentement et accélèrent (ease-in). Pour un compteur, ease-out donne un effet professionnel : le chiffre saute rapidement aux ordres de grandeur élevés, puis ralentit pour arriver précisément à la valeur finale.
Quatre courbes utiles à connaître. easeOutQuad : 1 - (1-t)*(1-t) — décélération douce. easeOutCubic : 1 - Math.pow(1-t, 3) — décélération plus marquée. easeInOutQuad : t < 0.5 ? 2*t*t : 1 - Math.pow(-2*t+2, 2)/2 — accélération puis décélération, agréable pour les animations longues. Pour un compteur de stats sur landing page, easeOutCubic donne le meilleur ressenti subjectif selon les tests UX.
Adaptation au contexte ouest-africain
Pour un site marketing ou portfolio basé à Dakar, Abidjan, Bamako ou Cotonou, les compteurs animés sont un classique des sections "nos chiffres" : nombre de clients servis, années d'expérience, projets livrés. Trois conseils pratiques pour qu'ils ne deviennent pas un fardeau performance. Premièrement, déclencher uniquement à l'IntersectionObserver, jamais au chargement. Deuxièmement, respecter prefers-reduced-motion en désactivant l'animation pour les utilisateurs qui ont opté contre le mouvement (utilisation de la vue subjective ou troubles vestibulaires). Troisièmement, formater les nombres en français avec toLocaleString('fr-FR') pour avoir 1 234 567 avec espaces insécables au lieu de 1234567 illisible.
Un compteur bien fait est une touche de polish qui élève la perception de qualité d'un site. Mal codé, il devient un caillou dans la chaussure des performances. La différence est dans la rigueur d'implémentation, pas dans le concept. Pour les patterns d'animation plus avancés, voir aussi les événements JavaScript expliqués.
Bibliothèques alternatives : CountUp.js et Anime.js
Si vous préférez une bibliothèque plutôt que de tout coder, deux options dominent en 2026. CountUp.js (~3 KB minifié) se concentre uniquement sur les compteurs animés : décimales configurables, séparateurs de milliers, easings prédéfinis, callback de fin. Idéal pour un site avec quelques compteurs simples sans autre besoin d'animation. Anime.js (~17 KB) est une bibliothèque d'animation générale qui gère bien plus que les compteurs : transformations CSS, SVG paths, timelines complexes. À choisir si vous prévoyez d'autres animations sur le même site.
Pour la majorité des cas PME ouest-africaines, le code vanilla en 30 lignes présenté plus haut suffit largement et économise les KB de chargement. Tester CountUp seulement quand le besoin se complique (ex : compteur qui change de valeur dynamiquement plusieurs fois sur la même page).
Compteurs et SEO : impact sur Core Web Vitals
Un compteur mal codé peut affecter trois métriques Core Web Vitals : LCP (Largest Contentful Paint), CLS (Cumulative Layout Shift) et INP (Interaction to Next Paint). Pour LCP : si le compteur fait partie du contenu au-dessus du fold, ne pas attendre IntersectionObserver — afficher la valeur finale immédiatement et ne déclencher l'animation qu'au scroll si l'élément est en bas de page.
Pour CLS : réserver l'espace du compteur via min-width en CSS pour éviter le layout shift quand la valeur passe de "0" à "1234567". Pour INP : ne pas bloquer le thread principal pendant l'animation. requestAnimationFrame est non-bloquant par nature ; setInterval avec calculs lourds peut provoquer des freezes. Tester systématiquement avec Lighthouse en mode mobile pour valider les trois métriques avant déploiement.
Patterns avancés : compteur de durée et compte à rebours
Une variante courante du compteur animé est le compte à rebours (countdown), utile pour les promotions e-commerce, les inscriptions à un webinaire ou les ventes flash. La logique change : on calcule la différence entre la date cible et l'instant courant, on rafraîchit toutes les secondes via setInterval(fn, 1000) ou setTimeout récursif. Pour les comptes à rebours longs (jours+heures+minutes+secondes), formater le résultat en plusieurs segments lisibles plutôt qu'en secondes brutes.
function compteARebours(elementId, dateCible) {
const el = document.getElementById(elementId);
function tick() {
const restant = dateCible - Date.now();
if (restant <= 0) { el.textContent = 'Terminé'; return; }
const j = Math.floor(restant / 86400000);
const h = Math.floor((restant % 86400000) / 3600000);
const m = Math.floor((restant % 3600000) / 60000);
const s = Math.floor((restant % 60000) / 1000);
el.textContent = `${j}j ${h}h ${m}m ${s}s`;
setTimeout(tick, 1000);
}
tick();
}
Pour les ventes flash de PME e-commerce ouest-africaines, ce pattern crée une urgence visuelle qui booste les conversions. Attention à toujours synchroniser sur l'horloge serveur via une requête API au chargement, sinon les utilisateurs avec horloge locale décalée verront une fin différente — source de réclamations clients.
Pour les sites e-commerce qui combinent compteur animé statistiques + compte à rebours promotion, viser un seul script JavaScript regroupant les deux logiques. Cela évite de charger deux bibliothèques distinctes pour des besoins similaires et garde le bundle final léger sur 4G ouest-africaine.
Lectures complémentaires
- Animations CSS sans JS
- Site one-page avec scroll fluide
- Référence : MDN — requestAnimationFrame
- Easing : easings.net (catalogue de fonctions de transition)
- Intersection Observer : MDN — IntersectionObserver