Number Counter Scroll
Six compteurs qui grimpent de 0 à leur cible par pas d'un 125e de la valeur, une écriture par image en requestAnimationFrame, sans lissage — 2,1 s à 60 Hz, 1 s à 120 Hz. Une IIFE complète : IntersectionObserver sur la zone défilante, une entrée par grille, jamais rejouée ; data-suffix lu et conservé à chaque image ; prefers-reduced-motion écrit la cible directement.
Vous aurez accès à la démo interactive avec un compte gratuit.
Cet effet fait partie d'Effect.Labs — 811 effets vanilla, une partie gratuite, une partie premium. Scroll comprend 70 effets, dont 12 gratuits. Découvrir la catégorie →
3 exemples d'usage



Comment ça marche
Le HTML vendu : un .demo-preview (flex centré, min-height: 300px, fond #0a0a0f, sans-serif) qui contient une zone défilante .scroll-container#counter-scroll de 280 px de haut, overflow: hidden auto, scroll-behavior: smooth, barre de défilement WebKit réduite à 4 px. À l'intérieur, .counter-inner (marges internes 24 px / 16 px) enchaîne deux intertitres .counter-section-label (11,2 px, capitales, blanc à 30 %) et deux grilles .st-counter-grid à deux colonnes (gap: 16px) : quatre cartes .counter-stat puis deux. Chaque carte (fond blanc à 3 %, bordure à 5 %, rayon 12 px) porte un .counter-value — 25,6 px, graisse 800, dégradé #22d3ee → #6366f1 découpé dans les chiffres par background: … text + -webkit-text-fill-color: transparent, font-variant-numeric: tabular-nums — et un .st-counter-label (10,4 px, capitales, blanc à 40 %). Entre les deux grilles, un .counter-spacer de 200 px et, après la seconde, un div vide de 200 px : 838 px de contenu pour 280 px de fenêtre, soit 558 px de course. Chaque valeur porte data-target (2847, 99, 156, 42, 60, 100) et data-suffix (vide, %, k, vide, fps, vide) et affiche la valeur initiale au chargement. Un libellé .scroll-hint-label pulsé (pulseHint, 2 s, opacité .3 ↔ .7) est calé en bas de .demo-preview.
Le JS vendu est une IIFE (aucune variable globale). Elle ouvre avec matchMedia('(prefers-reduced-motion: reduce)').matches et récupère #counter-scroll. La fonction animateCounter(el) lit parseInt(el.getAttribute('data-target'), 10) et el.getAttribute('data-suffix') || '' ; sous reduce elle écrit directement t + s. Sinon : l = t / 125, boucle i += l — chaque image écrit Math.floor(i) + s, la dernière écriture pose t + s. Mesuré sur 2847 : pas de 22,78 par image (45, 68, 91 … 2824, 2846, 2847), 124 ou 125 images selon l'arrondi flottant, 1,04 s dans un Chromium cadencé à 121 images par seconde ; sur un écran à 60 Hz la même montée dure 2,08 s. Le suffixe est conservé à chaque image : « 0% », « 1% » … « 99% », « 0k » … « 156k », « 0fps » … « 60fps ».
Le déclencheur : un IntersectionObserver dont la racine est #counter-scroll (seuil .3) observe chaque .st-counter-grid. À la première entrée il appelle animateCounter sur chaque .counter-value de la grille, puis unobserve — le comptage n'est jamais rejoué. La première grille est à 67 px du haut de la zone (280 px de hauteur) : elle est visible au chargement et ses quatre compteurs partent immédiatement ; la seconde grille (à 510 px) attend un défilement d'au moins 230 px. Sous prefers-reduced-motion ou sans IntersectionObserver, la valeur finale est écrite en une passe sur tous les .counter-value. L'IIFE garantit qu'il n'y a aucun conflit si un autre effet est collé sur la même page.
Accessibilité
- prefers-reduced-motion géré :
matchMedia('(prefers-reduced-motion: reduce)').matchesest testé à l'initialisation et court-circuite l'animation — la valeur finale et son suffixe sont écrits directement. La pulsationpulseHintpeut être coupée sous la même requête média avec@media (prefers-reduced-motion: reduce) { .scroll-hint-label { animation: none } }. - Un compteur est un
divréécrit jusqu'à 125 fois en une à deux secondes : un lecteur d'écran qui le rencontre pendant la montée lit une valeur intermédiaire, et une régionaria-livele ferait bégayer. Ne posez pasaria-livesur.counter-value; donnez la valeur finale dans unaria-labelsur.counter-stat(« 2847 utilisateurs ») et marquez le chiffre animéaria-hidden="true", ou placez la cible dans un élément visuellement masqué. - Contraste sur
#0a0a0f: les chiffres vont de 10,9:1 (#22d3ee) à 4,4:1 (#6366f1) — corrects à 25,6 px. Les libellés.st-counter-labelà blanc 40 % font 3,8:1 à 10,4 px, sous le seuil 4,5:1 du texte courant ; les intertitres à 30 % 2,6:1. Passez les libellés àrgba(255,255,255,.55)(5,4:1) pour respecter le critère. - La zone
.scroll-containern'est pas focalisable par défaut : au clavier, elle ne défile qu'après un clic. Ajouteztabindex="0"et unaria-label. L'observateur d'intersection fonctionne aussi sans défilement de l'utilisateur (grille déjà visible) et au clavier — la première grille est animée au chargement sans aucune interaction. Le libellé.scroll-hint-labelest positionné par rapport à.demo-preview: sans ce wrapper, il se cale au bas de la fenêtre, loin du conteneur.
Compatibilité navigateur
JavaScript ES5 (var, function), aucune dépendance ; il ne requiert que requestAnimationFrame et IntersectionObserver. La contrainte vient du CSS : la valeur text dans le raccourci background (background-clip: text non préfixé — Chrome et Edge 120+, Firefox 49+, Safari 14+) et overflow: hidden auto à deux mots-clés (Chrome 68+, Firefox 61+, Safari 13.1+). IntersectionObserver existe depuis Chrome 51, Firefox 55 et Safari 12.1. Tout est du JavaScript classique : l'effet fonctionne dans Firefox, contrairement aux animations au défilement en CSS natif (animation-timeline) de la même catégorie.
Sans JavaScript, la grille s'affiche avec ses valeurs initiales (« 0 », « 0% », « 0k »…) sans animation. Avant Chrome/Edge 120 (décembre 2023) ou Safari 14, le raccourci background: … text est rejeté en bloc : plus de dégradé, mais -webkit-text-fill-color: transparent reste appliqué et les chiffres sont invisibles — pour couvrir ces versions, ajoutez -webkit-background-clip: text après le raccourci. Firefox ignore les règles ::-webkit-scrollbar et affiche sa barre native.
Le code
Structure HTML à coller dans votre page (CSS + JS disponibles avec un compte premium) :
<div class="demo-preview">
<div class="scroll-container" id="counter-scroll">
<div class="counter-inner">
<div class="counter-section-label">Statistiques du projet</div>
<div class="st-counter-grid">
<div class="counter-stat">
<div class="counter-value" data-target="2847" data-suffix="">0</div>
<div class="st-counter-label">Utilisateurs</div>
</div>
<div class="counter-stat">
<div class="counter-value" data-target="99" data-suffix="%">0%</div>
<div class="st-counter-label">Satisfaction</div>
</div>
<div class="counter-stat">
<div class="counter-value" data-target="156" data-suffix="k">0k</div>
<div class="st-counter-label">Telechargements</div>
</div>
<div class="counter-stat">
<div class="counter-value" data-target="42" data-suffix="">0</div>
<div class="st-counter-label">Pays</div>
</div>
</div>
<div class="counter-spacer">
<p>Scrollez pour animer les compteurs</p>
</div>
<div class="counter-section-label">Performance</div>
<div class="st-counter-grid">
<div class="counter-stat">
<div class="counter-value" data-target="60" data-suffix="fps">0fps</div>
<div class="st-counter-label">Fluidite</div>
</div>
<div class="counter-stat">
<div class="counter-value" data-target="100" data-suffix="">0</div>
<div class="st-counter-label">Score Lighthouse</div>
</div>
</div>
<div style="height: 200px;">
</div>
</div>
</div>
<span class="scroll-hint-label">Scrollez pour explorer</span>
</div>
HTML + CSS + JS complet, prêt à coller — avec des centaines d'effets premium.
Personnaliser
Options passées à l'API ou attributs data-* :
| Option / propriété | Défaut | Effet |
|---|---|---|
Nombre de pas (JS — t / 125) |
125 images (2,1 s à 60 Hz, 1 s à 120 Hz) | Une image par pas : la durée est en images, pas en millisecondes — elle double à 30 Hz et se divise par deux à 120 Hz (mesuré : 1,04 s à 121 images/s). 60 pas = une seconde à 60 Hz ; 250 = quatre secondes. Pour une durée fixe, calculez i = t × (performance.now() − start) / 2000 plafonné à t. |
Cible (HTML — data-target) |
2847, 99, 156, 42, 60, 100 | Entier lu par parseInt : « 99.7 » devient 99, « 2 847 » devient 2, « 1,5 » devient 1. Aucun séparateur de milliers à l'écriture (« 2847 ») : pour « 2 847 », formatez les deux écritures avec new Intl.NumberFormat('fr-FR').format(…). |
Suffixe (HTML — data-suffix) |
vide, %, k, fps | Lu à chaque écriture et concaténé aux deux textContent : pendant la montée chaque image affiche Math.floor(i) + s, l'arrivée pose t + s. « 0% » … « 99% », « 0k » … « 156k », « 0fps » … « 60fps ». Pour placer l'unité dans un élément voisin (<small>) plutôt que dans l'attribut, laissez data-suffix vide et écrivez l'unité dans le HTML — les trois scènes de la page exemples illustrent cette approche. |
| Déclencheur (JS — à ajouter) | IntersectionObserver sur #counter-scroll, seuil .3, une entrée par grille, jamais rejouée | L'observateur est inclus dans le code vendu. Seuil 1 = la grille doit être entièrement visible ; 0 = un pixel suffit. Pour rejouer à chaque passage, ne désobservez pas et réécrivez « 0 + suffixe » à la sortie (!isIntersecting). Pour un déclenchement sur la fenêtre plutôt que sur la zone interne, passez root: null. |
| Décalage entre compteurs (JS — à ajouter) | 0 ms (départ simultané par grille) | Dans le déclencheur, setTimeout(function () { animateCounter(el) }, k × 150) sur le k-ième compteur donne une cascade de gauche à droite ; 0 conserve la révélation simultanée, plus juste quand les chiffres sont de même rang. |
Courbe (JS — i += l) |
linéaire | Incrément constant jusqu'à l'arrêt net. Pour un ralentissement en fin de course, comptez les images k et écrivez i = t × (1 − Math.pow(1 − k / 125, 3)) : les premiers chiffres défilent vite, les derniers se posent. |
Couleur des chiffres (CSS — .counter-value) |
dégradé 135° #22d3ee → #6366f1, 1.6rem, graisse 800 | Le dégradé est découpé dans le texte (background: … text + -webkit-text-fill-color: transparent). Pour une couleur unie, remplacez ces deux lignes par color. Gardez tabular-nums : sans lui, la largeur des chiffres change à chaque image et la carte tremble. |
Zone défilante (CSS — .scroll-container) |
280 px, overflow: hidden auto, scroll-behavior: smooth |
Le conteneur de démonstration. Dans une vraie page, supprimez-le et passez root: null au déclencheur : la fenêtre devient la racine. scroll-behavior: smooth ne concerne que les défilements programmatiques et les ancres. Sans le wrapper .demo-preview, retirez aussi .scroll-hint-label, qui perd son repère. |
FAQ
IntersectionObserver (racine = #counter-scroll, seuil .3) la détecte immédiatement et lance animateCounter sur ses quatre compteurs. La seconde grille est à 510 px et attend un défilement d'au moins 230 px avant d'entrer dans le seuil. C'est pourquoi l'effet porte le suffixe « Scroll » : le déclencheur est invariant, mais la seconde section ne s'anime qu'à condition de faire défiler.target / (2000 / 16), soit 125 images aussi, même montée linéaire, même Math.floor — mais un habillage et un cycle de vie différents. Counter Scroll est isolé dans une IIFE avec un observateur par carte au seuil .5, un drapeau dataset.counted, une cascade de 150 ms entre les cartes, le suffixe dans un span séparé, et un fondu montant sur chaque carte. Number Counter Scroll est la version « grille de tableau de bord » : deux sections titrées, cartes bordées, chiffres en dégradé et tabular-nums, départ simultané de toute une grille. Choisissez fx-0544 pour un bandeau de quatre chiffres avec cascade et fondu ; celui-ci pour des sections de statistiques à plusieurs grilles avec départ simultané par groupe.requestAnimationFrame, un par rafraîchissement de l'écran. Mesuré : 1,04 s dans un Chromium à 121 images par seconde ; sur un écran à 60 Hz, 2,08 s ; sur un portable en économie d'énergie à 30 Hz, plus de quatre secondes ; dans un onglet en arrière-plan, la boucle est suspendue et reprend au retour. Pour une durée identique partout, mémorisez performance.now() au départ et calculez la valeur à partir du temps écoulé (t × Math.min(1, (now − start) / 2000)) plutôt que d'accumuler un pas fixe. Vous obtiendrez alors aussi la même durée pour 42 et pour 2847, ce que le code fait déjà : le pas est proportionnel à la cible.