Accueil / Articles

Des compteurs à décompte sans dérive en React : de setTimeout à du CSS pur

Comparez setTimeout, requestAnimationFrame et une technique CSS sans JavaScript pour les compteurs à rebours en React, y compris le truc du délai unique qui maintient les chiffres synchronisés.

Éditeur : REACTAPP.TOP17 min de lecture4011 mots

Sources: How I Build a Countdown Timer in React

Une carte de bon avec une date d’expiration nécessite une horloge visible : heures, minutes et secondes qui s’égrènent jusqu’à ce que l’offre disparaisse. Cet élément est minuscule, mais il a une grande importance pour le produit. Un compteur qui accuse un retard par rapport au temps réel, saute de deux secondes d’un coup ou fait tourner silencieusement le ventilateur d’un ordinateur portable érode la confiance de l’utilisateur en tout ce qui l’entoure. Ce guide présente trois méthodes pour créer ce compte à rebours en React, explique précisément pourquoi la première méthode présente des erreurs, et se termine par une technique basée uniquement sur CSS qui permet de gérer des centaines de compteurs sans aucune instruction JavaScript par tick.

Le problème en une phrase

Chaque bon possède une valeur claim_end_time. L’affichage doit indiquer le temps restant jusqu’à ce moment, formaté en HH:MM:SS, mis à jour toutes les secondes, et doit s’arrêter à zéro lorsque le bon expire.

Les calculs sont simples. La partie difficile consiste à décider comment et quand les recalculer, et ce choix détermine la précision, la consommation en CPU ainsi que les actions possibles lorsque le temps imparti est épuisé.

Méthode 1 : une chaîne de setTimeout, et pourquoi elle dérive

L’implémentation intuitive consiste en une fonction qui met à jour l’affichage puis se planifie elle-même une seconde plus tard.

function tick() {
  updateDisplay();
  setTimeout(tick, 1000);
}
tick();

Celui-ci se lit clairement et semble fonctionner lors d’un test rapide. Le problème réside dans la manière dont les compteurs temporels sont gérés. Le JavaScript de la page s’exécute sur un seul thread principal, et le cycle d’événements lui transmet une tâche à la fois. Un rappel de compteur n’est qu’une autre tâche, mise en file derrière les gestionnaires de clics, les rappels réseau, les opérations d’affichage et tout ce que React est en train de synchroniser. La valeur 1000 que vous passez constitue une valeur minimale, et non une promesse. Si le thread est occupé au bout d’une seconde, le rappel attend, et vous pourriez donc le recevoir après 1 050 ms ou 1 200 ms au lieu de 1 000 ms. Si vous souhaitez vous rafraîchir la mémoire quant au pourquoi du comportement du thread principal, consultez notre article sur le thread unique de JavaScript par rapport au moteur multi-processeurs du navigateur.

C’est la chaîne des événements qui transforme un retard occasionnel en un décalage progressif visible. Chaque événement planifie l’prochain événement à partir de lui-même, de sorte que tout retard est transmis à tous les événements suivants. Rien ne permet jamais de ramener cette séquence en phase avec l’horloge murale. Laissez un tel cycle fonctionner pendant dix minutes, comparez-le avec Date.now(), et vous verrez qu’il reste en retard.

Un chronomètre qui diminue un compteur à chaque événement mesure les appels de fonction, et non le temps écoulé, et la limitation des onglets en arrière-plan aggrave encore ce décalage.

Méthode 2 : requestAnimationFrame et recalculation à partir de l’horloge

requestAnimationFrame (rAF) demande au navigateur d’appeler votre fonction juste avant la prochaine redessin, ce qui se produit sur la plupart des écrans environ 60 fois par seconde. Cependant, la fréquence n’est pas l’élément important. Le changement essentiel est conceptuel : vous cessez de demander « une seconde supplémentaire s’est-elle écoulée ? » pour plutôt demander « combien de temps reste-t-il à cet instant précis ? »

function updateTime() {
  const now = Date.now();
  const remaining = endTime - now;
  if (remaining <= 0) {
    display('00:00:00');
    return;
  }
  display(format(remaining));
  requestAnimationFrame(updateTime);
}
requestAnimationFrame(updateTime);

Dans chaque frame, le temps restant est calculé à partir de zéro, en utilisant Date.now() comme unique source de vérité. Il n’y a ni compteur d’intervalles, ni intervalles accumulés. Si un frame arrive en retard, le suivant lit simplement à nouveau l’heure et affiche la valeur correcte. Les erreurs ne peuvent pas s’accumuler, car rien n’est transmis d’un frame à l’autre à part l’heure de fin elle-même. Le même principe fonctionne également pour la version setTimeout : si chaque callback recalcule à partir de Date.now() au lieu de décrémenter un compteur, l’affichage s’autocorrigera même lorsque les callbacks arrivent en retard.

Une objection légitime : la valeur visible ne change qu’une fois par seconde, alors pourquoi effectuer des opérations 60 fois par seconde ? Dans React, la partie coûteuse n’est pas la soustraction mais le re-render déclenché par setState. Ainsi, la boucle compare à chaque frame et met à jour l’état uniquement lorsque la valeur correspondant à une seconde entière change réellement.

Mettre à jour l’état uniquement lorsque la seconde change

C’est cette idée mise en pratique à l’intérieur d’un composant. Notez que endTime est une date Unix exprimée en secondes, c’est pourquoi elle est multipliée par 1000 avant d’être comparée à Date.now().

const updateTime = (): void => {
  const now = Date.now();
  const timeTillStop = endTime * 1000 - now;
  if (timeTillStop <= 0) {
    setTimeLeft({ hours: 0, minutes: 0, seconds: 0 });
    onNoCountdownTimeLeft?.();
    return;
  }
  const totalSeconds = Math.floor(timeTillStop / 1000);
  if (totalSeconds !== lastSecondsRef.current) {
    lastSecondsRef.current = totalSeconds;
    const hours = Math.floor(totalSeconds / 3600);
    const minutes = Math.floor((totalSeconds % 3600) / 60);
    const seconds = totalSeconds % 60;
    setTimeLeft({ hours, minutes, seconds });
  }
  animationRef.current = requestAnimationFrame(updateTime);
};

Deux références assurent le travail principal. lastSecondsRef contient la seconde affichée précédemment. Étant une référence, écrire dedans ne provoque jamais de rendu ; il s’agit simplement d’une case mémoire qui persiste entre les frames. Par conséquent, la boucle s’exécute à la fréquence des frames, tandis que React ne rend qu’une fois par seconde au maximum. La fonction de rappel optionnelle onNoCountdownTimeLeft est appelée une fois que le temps est écoulé, permettant au composant parent de cacher la carte, de récupérer à nouveau les données ou d’afficher un état « expiré ».

La deuxième référence, animationRef, stocke l’ID retourné par requestAnimationFrame. La fonction de nettoyage de l’effet l’utilise pour annuler le frame en attente lorsque le composant est démonté ou que endTime change :

return () => {
  if (animationRef.current) {
    cancelAnimationFrame(animationRef.current);
  }
};

Sans cette opération de nettoyage, une boucle continuerait à s’exécuter après que l’utilisateur ait quitté la page, appelant setState sur un composant désinstallé et gaspillant ainsi des ressources CPU. Avec elle, le composant fonctionne correctement, se rend facilement et se corrige automatiquement.

Quelques remarques pratiques. Les navigateurs suspendent les appels rAF dans les onglets cachés, ce qui est en fait utile ici : la boucle cesse de consommer des ressources en arrière-plan, et le premier frame après que l’onglet devient visible lit la valeur du chronomètre et passe directement à la bonne valeur. La comparaison par frame est négligeable pour un seul compteur, mais elle s’accumule avec des dizaines de cartes.

Méthode 3 : un compte à rebours entièrement en CSS

La version rAF exécute toujours du JavaScript à chaque frame. La troisième méthode élimine complètement ce phénomène : le JavaScript calcule un seul nombre lors du montage du composant, et à partir de là, le moteur d’animation du navigateur gère chaque mise à jour.

Le compte à rebours devient une animation CSS qui parcourt les chiffres, avec un délai négatif pour commencer au milieu de la séquence au moment opportun.

Une précision sur les performances avant d’aller plus loin. On peut lire que les animations CSS s’exécutent toujours sur le thread du composant et ne peuvent donc jamais être bloquées. Cela n’est vrai que pour un petit ensemble de propriétés, principalement transform, opacity et certains filtres. L’animation des propriétés personnalisées enregistrées ainsi que du contenu généré, comme le fait cette technique, est gérée par le moteur de style du navigateur sur le thread principal. Le véritable avantage est différent mais tout aussi significatif : il n’y a pas de temporiseurs, pas d’état React et pas de rendus à chaque tick, et le planificateur intégré au navigateur, qui lie l’avancement de l’animation à la chronologie du document, maintient les valeurs correctes.

Pour que cela fonctionne, quatre fonctionnalités CSS doivent coopérer. Nous allons les examiner une par une.

Étape 1 : déclarer des entiers animables avec @property

Une première tentative évidente consiste à animer directement la propriété content. Cela échoue, car content n’est pas interprétable : le navigateur ne sait pas comment une chaîne « 59 » se transforme en « 58 ». Ce dont vous avez besoin, c’est d’un nombre que le navigateur puisse animer, et une propriété personnalisée enregistrée fournit précisément cela.

@property --s {
  syntax: '<integer>';
  inherits: true;
  initial-value: 59;
}

@property enregistre --s avec un type spécifique. En déclarant syntax: '<integer>', on indique au navigateur que la valeur est un véritable entier et non un token opaque, ce qui permet son interpolation et donc sa mise en animation. La valeur inherits: true permet aux éléments descendants, y compris les pseudo-éléments ::after, de lire la valeur animée depuis leur élément parent. Cela signifie qu’une seule animation appliquée au conteneur du chronomètre affecte chaque chiffre à l’intérieur. Pour en savoir plus sur les propriétés enregistrées et leurs différences avec les propriétés personnalisées classiques, consultez notre guide sur les propriétés CSS personnalisées en temps de exécution et @property.

Chaque unité dispose de sa propre propriété enregistrée. Les heures commencent à 99, ce qui constitue la limite maximale affichable par ce chronomètre.

@property --s {
  syntax: '<integer>';
  inherits: true;
  initial-value: 59;
} /* seconds */
@property --m {
  syntax: '<integer>';
  inherits: true;
  initial-value: 59;
} /* minutes */
@property --h {
  syntax: '<integer>';
  inherits: true;
  initial-value: 99;
} /* hours   */

Étape 2 : afficher l’entier sous forme de texte justifié avec counter()

L’entier doit encore être transformé en texte justifié à zéro (05, et non 5). La fonction counter() affiche la valeur d’un compteur à l’intérieur de content, et son style intégré decimal-leading-zero assure automatiquement le justage à deux chiffres.

Le lien entre la propriété personnalisée et le compteur se fait via counter-reset : il définit un compteur nommé sur la valeur actuelle de la propriété, et counter() la lit à nouveau. Ce fragment est en CSS, même si la source le qualifie autrement.

.seconds-digit::after {
  counter-reset: sc var(--s); /* set the "sc" counter = current --s value */
  content: counter(sc, decimal-leading-zero); /* render as "00"–"59" */
}
.minutes-digit::after {
  counter-reset: mc var(--m);
  content: counter(mc, decimal-leading-zero);
}
.hours-digit::after {
  counter-reset: hc var(--h);
  content: counter(hc, decimal-leading-zero);
}

Chaque fois que --s change de valeur entière, le moteur de style recalcule counter-reset, ce qui met à jour content du pseudo-élément en conséquence. Aucun script n’est impliqué dans cette mise à jour.

Étape 3 : faire varier les valeurs avec steps()

Par défaut, une animation effectue des transitions continues entre les keyframes. Une horloge ne doit pas glisser ; elle doit sauter d’un chiffre à l’autre. La fonction de temporisation steps(N, end) divise la durée en N sauts discrets et égaux.

@keyframes tick-s {
  from {
    --s: 59;
  }
  to {
    --s: -1;
  }
}
animation: tick-s 60s steps(60, end) infinite;

Sixty steps répartis sur une durée de soixante secondes signifient un saut par seconde : 59 au début, 58 après une seconde, 0 après 59 secondes, et retour à 59 au début de la prochaine itération.

Le mot-clé end (équivalent à jump-end) place chaque saut à la fin de son intervalle. À exactement t = 0, l’animation affiche encore sa valeur from, car le premier saut n’a pas encore eu lieu. Ce détail est important lorsque des retards négatifs entrent en jeu : un retard qui intervient exactement à t = 0 affiche 59 plutôt que 58.

Terminer les keyframes à -1 peut sembler étrange, mais c’est intentionnel. L’animation interprète une plage de 60 entiers, et en raison de jump-end, elle revient à from avant même que la valeur finale n’apparaisse. La séquence visible compte donc exactement 59 valeurs allant de 0 en pas unifiés.

Chaque unité possède ses propres keyframes et son propre nombre de pas. Les minutes nécessitent 60 pas de 60 secondes chacun, tandis que les heures nécessitent 100 pas d’une heure chacun :

@keyframes tick-s {
  from {
    --s: 59;
  }
  to {
    --s: -1;
  }
} /* 60 steps × 1s  = 60s  */
@keyframes tick-m {
  from {
    --m: 59;
  }
  to {
    --m: -1;
  }
} /* 60 steps × 60s = 1hr  */
@keyframes tick-h {
  from {
    --h: 99;
  }
  to {
    --h: -1;
  }
} /* 100 steps × 1hr = 100hr */

Étape 4 : commencer au milieu de la séquence avec un délai négatif

C’est le tour de force principal. Lorsque animation-delay est négatif, le navigateur considère que l’animation a commencé il y a autant de secondes en arrière, ce qui fait qu’elle débute au point où elle aurait dû arriver à ce moment-là.

Disons qu’il reste 23 secondes dans la minute en cours. L’animation des secondes commence à 59 et doit déjà avoir décliné de 36 fois pour afficher 23 ; donc un retard de -36s la place à la bonne position. En général :

delay = -(59 - s)      where s = remaining seconds within the current minute

Pour appliquer cela aux trois unités, il faut d’abord obtenir le temps restant total en secondes, calculé à partir de la date et heure de fin :

const remaining = endTime - Math.floor(Date.now() / 1000);

Ce total est ensuite divisé en parts correspondant à chaque unité. Les heures sont limitées à 99 car l’animation des heures ne couvre que 100 étapes :

const s = remaining % 60;
const m = Math.floor(remaining / 60) % 60;
const h = Math.min(Math.floor(remaining / 3600), 99);

Tenez compte de cette limitation en tant que contrainte liée au produit. Un bon d’achat qui expire dans cinq jours affichera 99:59:59 et semblera bloqué tant qu’il ne reste pas moins de 100 heures. Si vos offres peuvent durer aussi longtemps, ajoutez une unité de jours ou changez de format.

Mettre les chiffres en synchronisation avec un seul retard

La prochaine étape évidente consiste à calculer un délai distinct par unité. Cela provoque un bug problématique : les secondes reviennent à 59 tandis que les minutes restent inchangées pendant une période beaucoup trop longue.

Pourquoi les délais séparés s’écartent

Prenons remaining = 125, ce qui correspond à 0 heure, 2 minutes et 5 secondes. Le calcul des délais pour les secondes et les minutes de manière indépendante donne :

delayS = -(59 - 5)       = -54s     → seconds virtual start: now - 54s
delayM = -(59 - 2) × 60  = -3420s   → minutes virtual start: now - 3420s

Chaque animation mesure ses sauts par rapport à un temps de départ virtuel privé. Les étapes secondes se déclenchent toutes les secondes, en comptant à partir de now - 54s ; les étapes minutes se déclenchent toutes les 60 secondes, en comptant à partir de now - 3420s. Comme les points de départ diffèrent, les limites correspondent à des moments différents sur l’horloge. Le chiffre des secondes atteint son prochain renouvellement ici :

now - 54 + 60 = now + 6s

Cependant, le chiffre des minutes ne progresse que lorsque :

now - 3420 + (58 × 60) = now - 3420 + 3480 = now + 60s

Ainsi, les secondes recommencent après six secondes, tandis que les minutes changent complètement toutes les une minute. Pendant 54 secondes, l’affichage montre 01:59 alors que la valeur réelle est inférieure à 01:00. L’heure est incorrecte pendant la majeure partie de chaque minute.

Une origine commune

La solution consiste à utiliser une origine commune pour les trois animations, obtenue en combinant les décalages en un seul retard :

const delay = -((99 - h) * 3600 + (59 - m) * 60 + (59 - s));
//           = -(  99×3600      +   57×60       +   54   )
//           = -(356400 + 3420 + 54)
//           = -359874s

Désormais, chaque animation est mesurée à partir de now - 359874s. Les limites entre leurs étapes sont toutes des multiples de leur propre durée d’étape, comptée à partir de ce point de référence. Prenons l’exemple : dans une fenêtre de 60 secondes, l’animation des secondes se trouve à la position 54, donc sa prochaine limite arrive après 6 secondes. Dans une fenêtre de 3 600 secondes, l’animation des minutes se trouve à la position 3 474, et sa prochaine limite est à 3 480, soit également après 6 secondes. Les deux valeurs changent ensemble à now + 6s et restent alignées tous les 60 secondes par la suite.

Comment un seul nombre encode trois chiffres

Le délai combiné fonctionne comme un nombre à base mixte, tout comme HH:MM:SS qui regroupe trois champs en une seule chaîne. L’encodage est réalisé à partir des trois composants suivants :

|delay| = (99 - h) × 3600    ← hours field,   each unit = 3600s
        + (59 - m) × 60      ← minutes field, each unit = 60s
        + (59 - s)           ← seconds field, each unit = 1s

Le décodage est ce que chaque animation effectue implicitement en ne prenant en compte que sa propre fenêtre :

seconds position = |delay| mod 60                 → display 59 - position
minutes position = floor((|delay| mod 3600) / 60) → display 59 - position
hours   position = floor(|delay| / 3600)          → display 99 - position

Exécuter l’exemple avec le décodeur confirme ce résultat. 359 874 modulo 60 donne 54, donc les secondes affichent 59 − 54 = 5. 359 874 modulo 3 600 donne 3 474, qui divisé par 60 et arrondi vers le bas donne 57, donc les minutes affichent 59 − 57 = 2. 359 874 divisé par 3 600 et arrondi vers le bas donne 99, donc les heures affichent 99 − 99 = 0. L’affichage est 00:02:05, ce qui est correct.

Les animations ne peuvent pas interférer entre elles car chaque champ représente un multiple exact du cycle du champ suivant : la partie heures est toujours un multiple de 3 600 et la partie minutes est toujours un multiple de 60, de sorte qu’aucune ne perturbe les autres parties.

Arrêt propre à 00:00:00

Les animations de comptage utilisent une itération infinie, ce qui signifie qu’elles ne s’arrêtent jamais seules. Lorsque le cycle des secondes atteint sa fin, il revient à 59, et un chronomètre qui atteindrait zéro recommencerait immédiatement à compter à rebours.

La solution consiste en une animation supplémentaire qui s’exécute une seule fois, exactement au moment de l’expiration, et qui fixe les trois propriétés à zéro. Ce bloc est également du CSS malgré son intitulé initial :

@keyframes freeze {
  to {
    --s: 0;
    --m: 0;
    --h: 0;
  }
}
.timer {
  animation-name: tick-s, tick-m, tick-h, freeze;
  animation-duration: 60s, 3600s, 360000s, 1ms;
  animation-timing-function: steps(60, end), steps(60, end), steps(100, end),
    step-start;
  animation-iteration-count: infinite, infinite, infinite, 1;
  animation-fill-mode: none, none, none, forwards;
}

JavaScript attribue le délai commun aux trois animations de mise à jour et le temps restant, sous forme de délai positif, à l’animation de figeage :

el.style.animationDelay = `${delay}s, ${delay}s, ${delay}s, ${remaining}s`;

Trois éléments interagissent ici :

  • step-start (équivalent à steps(1, jump-start)) passe directement au keyframe final dès le début de l’animation, plutôt qu’à la fin de sa durée de 1 ms, ce qui fait que les trois valeurs deviennent zéro instantanément à l’expiration du délai.
  • animation-fill-mode: forwards maintient l’application du keyframe final après la fin de l’animation, ce qui permet aux valeurs zéro de persister indéfiniment.
  • L’ordre dans animation-name est important : lorsque plusieurs animations définissent la même propriété, celle qui est listée en dernier l’emporte. Comme freeze vient en dernier, ses valeurs zéro écrasent les animations de mise à jour, qui continuent de s’exécuter en arrière-plan sans effet visible.
  • Si le bon d’achat a déjà expiré au chargement de la page, le temps restant est nul, le délai de gel est de 0s, et le compteur affiche immédiatement zéro.

    Quels sont les inconvénients de l’approche CSS

    Une conception sans JavaScript présente des limites réelles, et celles-ci devraient guider votre décision :

    • Aucun mécanisme de détection d’expiration. Il n’y a ni fonction de rappel, ni événement, ni changement d’état lorsque le compteur atteint zéro. Si l’expiration doit déclencher une appel API, cacher la carte ou afficher quelque chose de différent, il vous faut un setTimeout distinct, ce qui restitue une partie de la simplicité initiale.
    • Style restreint. counter() génère du texte brut. La police, la couleur et le layout sont entièrement sous votre contrôle, mais tout ce qui dépend de la valeur actuelle en JavaScript, comme le changement de couleur en rouge après 60 secondes ou l’ajout d’une classe à un certain seuil, nécessite des manipulations supplémentaires.
    • Precision à la seconde entière. Le temps restant est arrondi à des secondes entières au moment du chargement, de sorte que les repères visibles peuvent être décalés d’une seconde par rapport aux vrais seuils de seconde. Cela pose peu de problème pour un compte à rebours promotionnel, mais ce n’est pas une horloge précise.
    • Support des navigateurs. Cette technique repose sur @property et les propriétés enregistrées animées ; vérifiez le support dans vos données de compatibilité actuelles pour votre ensemble de navigateurs avant de l’utiliser.

    En contrepartie, pour un compte à rebours en lecture seule qui n’a besoin que de progresser puis s’arrêter, il est extrêmement efficace : une seule séquence d’animation par carte, pas de JavaScript à chaque mise à jour, aucune dérive, et rien à nettoyer lors du désinstallation. Il se révèle particulièrement utile lorsque une page affiche en même temps des centaines de cartes de bons d’achat.

    Le composant React pour le chronomètre CSS

    La version CSS modifie la forme du composant. Il n’y a ni état, ni boucle d’animation, ni opération de nettoyage. Le composant lit l’heure une seule fois, définit une propriété de style et affiche le markup correspondant. Il n’a besoin que de deux hooks de React :

    import { useEffect, useRef } from 'react';
    

    Le composant ci-dessous calcule les secondes restantes, les divise en unités, crée le délai commun et écrit ces quatre délais dans l’élément. La propriété type ne fait que changer entre deux styles visuels pour les chiffres.

    interface CSSCountdownTimerProps {
      endTime: number; // Unix timestamp in seconds, same as existing prop
      type?: 'solid' | 'regular';
    }export function CSSCountdownTimer({
      endTime,
      type = 'solid',
    }: CSSCountdownTimerProps) {
      const timerRef = useRef<HTMLDivElement>(null);  useEffect(() => {
        const el = timerRef.current;
        if (!el) return;    const now = Math.floor(Date.now() / 1000);
        const remaining = Math.max(0, endTime - now);    const s = remaining % 60;
        const m = Math.floor(remaining / 60) % 60;
        const h = Math.min(Math.floor(remaining / 3600), 99);    const delay = -((99 - h) * 3600 + (59 - m) * 60 + (59 - s));    el.style.animationDelay = `${delay}s, ${delay}s, ${delay}s, ${remaining}s`;    // No cleanup needed.
        // There are no JS timers, no rAF loops, no subscriptions.
        // CSS animations stop automatically when the element is removed from the DOM.
      }, [endTime]);  return (
        <div ref={timerRef} className={`timer ${type}`}>
          <span
            className={type === 'solid' ? 'digit hours-digit' : 'hours-digit'}
          />
          <span className="sep">:</span>
          <span
            className={type === 'solid' ? 'digit minutes-digit' : 'minutes-digit'}
          />
          <span className="sep">:</span>
          <span
            className={type === 'solid' ? 'digit seconds-digit' : 'seconds-digit'}
          />
        </div>
      );
    }
    

    useEffect s’exécute après que le navigateur ait affiché l’élément, la première frame peut brièvement montrer les animations à leurs positions par défaut avant que le délai ne soit appliqué ; useLayoutEffect, ou le calcul du délai pendant le rendu et son transmission en tant que style inline, permet d’éviter cette apparition fugace. Deuxièmement, l’effet dépend de endTime, donc si la propriété change, les délais sont recalculés et les animations se répositionnent correctement.

    Choix entre les trois approches

    • Si quelque chose doit se produire lorsque le délai est écoulé, comme une mise à jour d’état, un rechargement ou une appel API, utilisez requestAnimationFrame avec une recomputation basée sur l’horloge. Cela vous donne un contrôle total et un endroit approprié pour le callback de fin de délai.
    • Si une page affiche de nombreux compteurs temporels et aucun d’eux ne déclenche de logique, recourez à la technique CSS et laissez le navigateur s’en charger.
    • Si vous utilisez une chaîne de setTimeout classique qui diminue un compteur, remplacez-la. Elle est imprecise et consomme encore des ressources sur le thread principal chaque seconde, ce qui combine les inconvénients des deux alternatives. Les deux méthodes peuvent également être combinées : le CSS pour l’affichage, et un seul compteur partagé pour la logique de fin de délai.

    Points clés

    • Les retards des compteurs temporels sont des valeurs minimales ; calculez le temps restant à partir de Date.now() à chaque mise à jour plutôt que de compter les ticks, et l’écart disparaîtra.
  • Avec rAF, conservez la dernière seconde affichée dans un référentiel et annulez l’affichage de la frame lors du démontage.
  • La version CSS repose sur quatre fonctionnalités : les entiers @property, counter() pour le texte aligné, steps() pour des sauts discrets, ainsi qu’un délai négatif pour commencer au milieu de la séquence.
  • Partagez un même délai entre toutes les unités afin que les chiffres avancent ensemble, et ajoutez une animation freeze pour conserver les zéros.