Scroll✨ Premium

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.

JavaScriptAnimationData

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

Indicateurs de bilan carbone dans un rapport RSE numérique — Number Counter Scroll exemple 1

① Indicateurs de bilan carbone dans un rapport RSE numérique

QuandLa section « Impact environnemental » d'un rapport de responsabilité sociétale en ligne : six métriques chiffrées — tonnes de CO₂ évitées, part d'énergie renouvelable, eau recyclée, emplois locaux, fournisseurs certifiés, kilomètres en mobilité douce — dans une grille qui arrive après le texte du chapitre, et dont les compteurs partent de zéro dès qu'elle entre dans la zone de lecture.
PourquoiUne valeur qui grimpe de 0 à 918 en 125 images rend l'ordre de grandeur perceptible : le lecteur voit le chiffre se construire, ce qu'une valeur posée d'emblée ne fait pas et qu'une barre de progression ne fait pas mieux — il n'y a pas de maximum à atteindre. Le pas linéaire convient à un document : pas d'accélération théâtrale, une montée régulière puis l'arrêt net sur la cible.
RéglagesGrille à trois colonnes (grid-template-columns: repeat(3, 1fr)), dégradé vert #4ade80 → #22d3ee, unités dans un <small> voisin du compteur plutôt que dans data-suffix (jamais relu), valeurs à quatre chiffres au plus pour se passer du séparateur de milliers, libellés à 55 % de blanc. Déclencheur : un observateur, racine = la zone de lecture, seuil .3, une seule fois.
Statistiques de saison sur le site officiel d'un club sportif amateur — Number Counter Scroll exemple 2

② Statistiques de saison sur le site officiel d'un club sportif amateur

QuandLa section « Bilan de saison » du site d'un club de football : matchs gagnés, buts marqués, taux de passes réussies, spectateurs, classement final — consultée sur mobile après un match, sous le score du jour, dans une colonne étroite.
PourquoiLe déclencheur à ajouter est à tir unique — unobserve dès la première entrée — et rien ne remet les compteurs à zéro : le supporter qui remonte vers le score puis redescend retrouve les chiffres posés. Chaque statistique est révélée une fois, dans sa montée complète, comme un résultat final qu'on ne rejoue pas ; les chiffres tabulaires (tabular-nums) évitent que la colonne tremble pendant le comptage.
RéglagesZone défilante de 288 px dans un gabarit de téléphone, grille deux colonnes conservée avec la dernière carte sur toute la largeur (grid-column: 1 / -1), chiffres en blanc plein (color à la place du dégradé et du -webkit-text-fill-color), classement « 2 » suivi d'un <small>e</small>, seuil .4.
Résultats d'un scrutin municipal sur le site d'une collectivité — Number Counter Scroll exemple 3

③ Résultats d'un scrutin municipal sur le site d'une collectivité

QuandLa page « Résultats » du site d'une mairie après un vote : inscrits, voix exprimées, bulletins nuls, taux de participation — quatre chiffres officiels, de même poids, sous le procès-verbal, animés en même temps quand la section entre dans l'écran du citoyen.
PourquoiLes quatre compteurs sont lancés dans la même image et atteignent leur cible à la 125e : la révélation est simultanée, sans hiérarchie. Des barres de progression auraient imposé une longueur à chaque chiffre — et donc un classement visuel entre des résultats officiellement équivalents. Ici seule la valeur change, le cadre est identique pour les quatre.
RéglagesQuatre cartes en ligne (repeat(4, 1fr)), couleur unie institutionnelle (#1e3a8a sur fond clair, dégradé retiré), pourcentage et unités dans un élément voisin, seuil .5 pour que la rangée soit à moitié visible avant de partir, aria-label avec la valeur finale sur chaque carte.

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)').matches est testé à l'initialisation et court-circuite l'animation — la valeur finale et son suffixe sont écrits directement. La pulsation pulseHint peut ê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 div réé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égion aria-live le ferait bégayer. Ne posez pas aria-live sur .counter-value ; donnez la valeur finale dans un aria-label sur .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-container n'est pas focalisable par défaut : au clavier, elle ne défile qu'après un clic. Ajoutez tabindex="0" et un aria-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-label est 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.

Chrome 120+✓ Complet
Firefox 61+✓ Complet (barre de défilement système : ::-webkit-scrollbar ignoré)
Safari 14+✓ Complet
Edge 120+✓ Complet
Mobile iOS 14+✓ Complet (défilement tactile de la zone)
Android Chrome 120+✓ Complet

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) :

index.html — structure
<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>
🔒 Débloquer le code complet — à partir de 2,99 € le 1er mois

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éfautEffet
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

Parce que la première grille est à 67 px du haut de la zone défilante (280 px de hauteur) : elle est déjà visible au chargement. L'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.
Le même pas — fx-0544 écrit 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.
Parce que la durée est comptée en images, pas en temps : 125 appels de 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.