Skip to content
← 1001 Widgets
N° 004 · ●○○

Pomodoro

A Pomodoro timer: 25 minutes of work, 5 of rest, and a signal when it switches. The right exercise to learn how to handle passing time in JavaScript.

HTMLJS
What you'll learn
  • Compute the time left from an end time
  • Start and stop a timer without duplicates
  • Play a sound with the Web Audio API
Why this language

Pourquoi un minuteur ? Parce que le temps est le piège classique du débutant. On croit qu'il suffit de retirer une seconde toutes les 1000 ms, et le minuteur se met à dériver dès que l'onglet passe en arrière-plan.

Ici, on ne compte pas les secondes : on retient l'heure de fin, et on recalcule le temps restant à chaque affichage. Le minuteur reste juste, même quand le navigateur ralentit.

Pourquoi en JavaScript simple ? Un minuteur tient en une cinquantaine de lignes. Pas besoin de framework pour comprendre setInterval, Date.now() et la mise à jour de la page.

How it works
  1. LE HTML

    Un libellé, le temps restant, deux boutons. L'élément <output> annonce le temps aux lecteurs d'écran.

    <div class="pomodoro">
      <p id="phase">Travail</p>
      <output id="temps" aria-live="polite">25:00</output>
      <button id="lancer" type="button">Démarrer</button>
      <button id="reset" type="button">Remettre à zéro</button>
    </div>
  2. L'ÉTAT

    On garde la phase en cours, le temps restant, et l'heure de fin quand le minuteur tourne.

    const DUREES = { travail: 25 * 60 * 1000, pause: 5 * 60 * 1000 };
    let phase = 'travail';
    let restant = DUREES.travail;
    let fin = null;
    let minuterie = null;
    
    const libelle = document.getElementById('phase');
    const temps = document.getElementById('temps');
    const lancer = document.getElementById('lancer');
  3. L'AFFICHAGE

    Des millisecondes vers mm:ss. Le titre de l'onglet suit, pour voir le temps depuis un autre onglet.

    function afficher(ms) {
      const s = Math.ceil(ms / 1000);
      const mm = String(Math.floor(s / 60)).padStart(2, '0');
      const ss = String(s % 60).padStart(2, '0');
      temps.textContent = `${mm}:${ss}`;
      document.title = `${mm}:${ss} · ${libelle.textContent}`;
    }
  4. LE TIC

    On ne retire pas une seconde : on recalcule ce qui reste jusqu'à l'heure de fin.

    function tic() {
      restant = fin - Date.now();
      if (restant <= 0) return basculer();
      afficher(restant);
    }
  5. DÉMARRER ET METTRE EN PAUSE

    Le même bouton fait les deux. On arrête toujours l'ancienne minuterie avant d'en créer une.

    function demarrerOuPause() {
      if (minuterie) {
        clearInterval(minuterie);
        minuterie = null;
        lancer.textContent = 'Reprendre';
        return;
      }
      fin = Date.now() + restant;
      minuterie = setInterval(tic, 250);
      lancer.textContent = 'Pause';
      tic();
    }
  6. BASCULER

    Fin d'une phase : on passe à l'autre, on repart de sa durée, et on prévient.

    function basculer() {
      phase = phase === 'travail' ? 'pause' : 'travail';
      libelle.textContent = phase === 'travail' ? 'Travail' : 'Pause';
      restant = DUREES[phase];
      fin = Date.now() + restant;
      bip();
      afficher(restant);
    }
  7. LE SIGNAL

    Un bip court avec l'API Web Audio, sans fichier son.

    let audio = null;
    function bip() {
      if (!audio) return;
      const o = audio.createOscillator();
      o.frequency.value = 880;
      o.connect(audio.destination);
      o.start();
      o.stop(audio.currentTime + 0.2);
    }
  8. LES ÉCOUTEURS

    Le son ne peut naître qu'après un clic : on crée l'AudioContext au premier Démarrer.

    lancer.addEventListener('click', () => {
      audio ??= new AudioContext();
      demarrerOuPause();
    });
    document.getElementById('reset').addEventListener('click', () => {
      clearInterval(minuterie);
      minuterie = null;
      phase = 'travail';
      libelle.textContent = 'Travail';
      restant = DUREES.travail;
      lancer.textContent = 'Démarrer';
      afficher(restant);
    });
Pitfalls

PIÈGE 1 : Compter les secondes

Pitfall

Mauvais : retirer 1000 ms au temps restant dans un setInterval de 1000 ms. Dès que l'onglet passe en arrière-plan, le navigateur ralentit les minuteries et le compte prend plusieurs secondes de retard.

Fix

Correct : retenir l'heure de fin avec Date.now() + durée, puis recalculer le temps restant à chaque tic. Le retard ne s'accumule jamais.

PIÈGE 2 : Lancer deux minuteries

Pitfall

Mauvais : appeler setInterval à chaque clic sur Démarrer. Deux clics, deux minuteries, et l'affichage clignote entre deux valeurs.

Fix

Correct : garder la référence renvoyée par setInterval, et appeler clearInterval avant d'en créer une nouvelle.

PIÈGE 3 : Le son qui ne sort pas

Pitfall

Mauvais : créer l'AudioContext au chargement de la page. Les navigateurs bloquent tout son tant que l'utilisateur n'a pas interagi.

Fix

Correct : créer l'AudioContext dans le gestionnaire du premier clic.

PERSONNALISER
  • Durées réglables : remplacez les valeurs de DUREES par deux champs <input type="number"> lus au clic sur Démarrer.
  • Couleur par phase : ajoutez une classe .is-pause sur le conteneur dans basculer(), et laissez le CSS changer la couleur du temps.
  • Un autre son : changez o.frequency.value (440 pour plus grave, 1320 pour plus aigu), ou jouez trois bips courts à la suite.
OPTIMISATION
  • Un tic toutes les 250 ms suffit : l'affichage ne change qu'une fois par seconde, et on évite un décalage visible d'une seconde au démarrage.
  • Après quatre cycles de travail, passez à une longue pause de 15 minutes : un compteur de cycles suffit.
  • Pour prévenir même quand l'onglet est caché, ajoutez l'API Notification, avec la permission demandée au premier clic.
VERS REACT

L'heure de fin et la minuterie vont dans un useRef : elles changent sans devoir redessiner le composant.

Le temps affiché va dans un useState, mis à jour par le tic.

Le useEffect qui crée la minuterie renvoie une fonction qui appelle clearInterval : sans elle, la minuterie continue de tourner après la fermeture du composant.

Variants
Comments
0/1000

Loading comments…

Audience measurement

With your consent, this site measures its traffic with Google Analytics and the Meta pixel, via Google Tag Manager. Nothing is loaded until you choose, and you can change your mind at any time with the "Cookies" link at the bottom of the page.