Make a pomodoro timer in JavaScript, in one HTML file

A pomodoro timer is a countdown that remembers when it should end, plus a loop of focus and break rounds. The smallest version fits in about 25 lines and needs nothing installed.

A pomodoro timer in JavaScript is a countdown with a saved end time. On Start, store Date.now() plus 25 minutes.

A setInterval repaints the digits a few times a second, and each repaint asks the clock how much time is left. The whole page is one .html file, so there is nothing to install.

Try the smallest working version. Press Start and it counts down from 25:00.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Smallest pomodoro timer</title>
<style>
  body {
    margin: 0; min-height: 100vh; display: grid; place-content: center; justify-items: center; gap: 14px;
    font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330;
  }
  #time { font: 700 64px/1 ui-monospace, Consolas, monospace; font-variant-numeric: tabular-nums; }
  #time.done { color: #16a34a; }
  button {
    font: inherit; font-size: 16px; padding: 10px 22px; border-radius: 10px;
    border: 0; background: #1d2330; color: #fff; cursor: pointer;
  }
  p { margin: 0; font-size: 13px; color: #4b5563; }
</style>
</head>
<body>
<div id="time">25:00</div>
<button id="go">Start</button>
<p id="note">Change 25 to 0.1 in the code to watch it finish in 6 seconds.</p>

<script>
  const LENGTH = 25 * 60 * 1000;   // 25 minutes, in milliseconds
  const time = document.getElementById('time');
  const go = document.getElementById('go');
  let endAt = 0;                   // the moment the timer should end
  let tick = null;                 // id of the running interval

  function paint() {
    const left = Math.max(0, endAt - Date.now());     // ask the clock, never count ticks
    const s = Math.ceil(left / 1000);
    time.textContent = String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0');
    if (left === 0) {
      clearInterval(tick);
      time.classList.add('done');
      go.textContent = 'Start again';
    }
  }

  go.addEventListener('click', () => {
    clearInterval(tick);                  // never keep two intervals running
    time.classList.remove('done');
    go.textContent = 'Restart';
    endAt = Date.now() + LENGTH;
    tick = setInterval(paint, 250);       // repaint a few times a second
    paint();
  });
</script>
</body>
</html>
The smallest pomodoro timer: one button, one number, about 25 lines. Change 25 to 0.1 in the code to watch it finish in six seconds.

The rest of this page explains each line, shows the mistake that makes timers lose seconds, and builds the full cycle of focus rounds and breaks.

How to make a pomodoro timer in JavaScript, step by step

The code in the example above has three parts. They run in this order:

Start saves the end time, an interval repaints, and clearInterval stops it at zero.
Start saves the end time, an interval repaints, and clearInterval stops it at zero.
  1. Save the end time. On click, endAt = Date.now() + LENGTH. Date.now() returns milliseconds since the start of 1970 (UTC), so adding a length in milliseconds gives the moment the round ends.
  2. Repaint on an interval. setInterval(paint, 250) calls paint four times a second and returns an id. Keep the id, because clearInterval(id) is the only way to stop it.
  3. Work out the time left. In paint, endAt - Date.now() is the milliseconds left. Divide by 1000, round up with Math.ceil, then split into minutes and seconds.
  4. Pad the digits. padStart(2, '0') fills a number from the left until it is two characters long, so 5 seconds shows as 05 and not 5.
  5. Stop at zero. When the time left is 0, call clearInterval and change the page to show the round is over.

Math.ceil is why the screen shows 25:00 for the first second and reaches 00:00 exactly when the round ends. With Math.floor, 24:59 would appear immediately.

Why it saves an end time instead of counting

The obvious first version does left = left - 1 inside a one-second interval. It works until the page is busy.

A timer function cannot run while other code is running, so a late tick is not made up later. The MDN page for setTimeout says the code cannot run until the thread that called it has finished.

A busy moment costs the tick counter its missed ticks. The clock reader catches up on the next repaint.
A busy moment costs the tick counter its missed ticks. The clock reader catches up on the next repaint.

The next example runs both versions side by side. Press Start both, then Freeze the page for 3 s, which keeps the page busy with a loop. When the page wakes up, the counter is behind and the clock reader is right.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Counting ticks vs reading the clock</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
  .box { background: #fff; border: 1px solid #e1e4ea; border-radius: 12px; padding: 10px 10px 12px; text-align: center; }
  .box h3 { margin: 0 0 6px; font-size: 13px; line-height: 1.25; min-height: 33px; }
  .box.bad { border-top: 4px solid #ea580c; }
  .box.good { border-top: 4px solid #16a34a; }
  .time { font: 700 36px/1.1 ui-monospace, Consolas, monospace; font-variant-numeric: tabular-nums; }
  .box code { display: block; margin-top: 6px; font: 12px ui-monospace, Consolas, monospace; color: #4b5563; }
  .row { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin: 12px 0 8px; }
  button { font: inherit; font-size: 14px; padding: 8px 14px; border-radius: 8px; border: 1px solid #c9ced6; background: #fff; cursor: pointer; }
  button.main { background: #1d2330; border-color: #1d2330; color: #fff; }
  .info { font-size: 13px; line-height: 1.5; text-align: center; color: #374151; margin: 0; }
  .info b { font-variant-numeric: tabular-nums; }
</style>
</head>
<body>
<div class="grid">
  <div class="box bad"><h3>Counts ticks</h3><div class="time" id="a">02:00</div><code>left = left - 1</code></div>
  <div class="box good"><h3>Reads the clock</h3><div class="time" id="b">02:00</div><code>endAt - Date.now()</code></div>
</div>
<div class="row">
  <button class="main" id="start">Start both</button>
  <button id="freeze">Freeze the page for 3 s</button>
</div>
<p class="info" id="info">Press Start, then Freeze, and compare the two numbers.</p>

<script>
  const fmt = (s) => String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0');
  const a = document.getElementById('a');
  const b = document.getElementById('b');
  const info = document.getElementById('info');
  let counted = 120;     // timer A: seconds left, lowered by one on every tick
  let endAt = 0;         // timer B: the moment it ends
  let startedAt = 0;
  let tickA = null;

  document.getElementById('start').addEventListener('click', () => {
    clearInterval(tickA);
    counted = 120;
    startedAt = Date.now();
    endAt = startedAt + 120 * 1000;
    // A believes every tick is exactly one second
    tickA = setInterval(() => { counted = Math.max(0, counted - 1); a.textContent = fmt(counted); }, 1000);
  });

  // B only repaints; the number comes from the clock
  setInterval(() => {
    if (!startedAt) return;
    const left = Math.max(0, Math.ceil((endAt - Date.now()) / 1000));
    b.textContent = fmt(left);
    const late = counted - left;
    info.innerHTML = 'Timer A is <b>' + late + ' s</b> ' + (late > 0 ? 'slow' : 'off by 0') + '. Timer B is exact.';
  }, 200);

  // Busy loop: no timer callback can run until it ends
  document.getElementById('freeze').addEventListener('click', () => {
    const t = Date.now();
    while (Date.now() - t < 3000) { /* do nothing, but keep the thread busy */ }
  });
</script>
</body>
</html>
Left: counts ticks. Right: reads Date.now(). Freeze the page and compare them.

The same idea keeps pause and resume honest. On pause, store the time left. On resume, build a new end time from it:

function pause() {
  remaining = Math.max(0, endAt - Date.now()); // time left right now
  endAt = null;                                // null means "not running"
  clearInterval(tick);
}
function resume() {
  endAt = Date.now() + remaining;              // a new end, same time left
  tick = setInterval(paint, 250);
}

setTimeout and setInterval goes deeper on ids, drift and why clearInterval sometimes seems to do nothing. A plain countdown to a date is in HTML countdown timer.

How to set the focus and break lengths

The Pomodoro Technique was developed by Francesco Cirillo, who split work into 25-minute rounds with 5-minute breaks. Guides to the method add a longer break of 15 to 30 minutes after every fourth round.

Keep those numbers in one object, so changing a length means changing one value:

const modes = {
  focus: { label: 'Focus',       minutes: 25 },
  short: { label: 'Short break', minutes: 5 },
  long:  { label: 'Long break',  minutes: 15 },
};

The cycle itself is a counter. Count finished focus rounds in done. When a focus round ends, done % 4 === 0 means a long break, anything else a short one. When any break ends, go back to focus.

Three short breaks, then one long break. Four dots show how many focus rounds are done.
Three short breaks, then one long break. Four dots show how many focus rounds are done.

A finished example: focus, breaks, dial and beep

This version adds the cycle, three boxes for your own lengths, a progress dial, round dots, a beep, and the time in the browser tab title.

It is the same two ideas as before, the end time and the repaint, with a small state around them.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pomodoro 25:00</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .app { max-width: 360px; margin: 0 auto; background: #fff; border: 1px solid #e1e4ea; border-radius: 16px; padding: 14px 16px 16px; text-align: center; }
  .tabs { display: flex; gap: 6px; justify-content: center; }
  .tabs button { flex: 1; font: inherit; font-size: 13px; padding: 7px 4px; border-radius: 8px; border: 1px solid #c9ced6; background: #fff; cursor: pointer; }
  .tabs button[aria-pressed="true"] { background: #1d2330; border-color: #1d2330; color: #fff; }
  .dial { position: relative; width: 210px; height: 210px; margin: 14px auto 6px; }
  .dial svg { width: 100%; height: 100%; transform: rotate(-90deg); }
  .dial circle { fill: none; stroke-width: 9; }
  .track { stroke: #e8eaee; }
  #ring { stroke: #e0452c; stroke-linecap: round; stroke-dasharray: 100; transition: stroke-dashoffset .25s linear; }
  [data-mode="short"] #ring { stroke: #16a34a; }
  [data-mode="long"] #ring { stroke: #2563eb; }
  .center { position: absolute; inset: 0; display: grid; place-content: center; }
  #time { font: 700 46px/1 ui-monospace, Consolas, monospace; font-variant-numeric: tabular-nums; }
  #label { font-size: 13px; color: #4b5563; margin-top: 4px; }
  .dots { display: flex; gap: 8px; justify-content: center; margin: 4px 0 10px; }
  .dots i { width: 11px; height: 11px; border-radius: 50%; border: 2px solid #c9ced6; box-sizing: border-box; }
  .dots i.on { background: #e0452c; border-color: #e0452c; }
  .row { display: flex; gap: 8px; justify-content: center; }
  .row button { font: inherit; font-size: 15px; padding: 9px 20px; border-radius: 10px; border: 1px solid #c9ced6; background: #fff; cursor: pointer; }
  .row button.main { background: #1d2330; border-color: #1d2330; color: #fff; min-width: 110px; }
  #msg { min-height: 20px; margin: 10px 0 4px; font-size: 13px; color: #374151; }
  .set { display: flex; gap: 8px; justify-content: center; margin-top: 8px; }
  .set label { font-size: 12px; color: #4b5563; display: grid; gap: 3px; }
  .set input[type="number"] { width: 70px; font: inherit; font-size: 16px; padding: 6px; border: 1px solid #c9ced6; border-radius: 8px; text-align: center; box-sizing: border-box; }
  .snd { display: block; margin-top: 10px; font-size: 13px; color: #4b5563; }
</style>
</head>
<body>
<div class="app" id="app" data-mode="focus">
  <div class="tabs" id="tabs">
    <button data-mode="focus" aria-pressed="true">Focus</button>
    <button data-mode="short" aria-pressed="false">Short break</button>
    <button data-mode="long" aria-pressed="false">Long break</button>
  </div>
  <div class="dial">
    <svg viewBox="0 0 100 100" aria-hidden="true">
      <circle class="track" cx="50" cy="50" r="44"></circle>
      <circle id="ring" cx="50" cy="50" r="44" pathLength="100"></circle>
    </svg>
    <div class="center"><div id="time">25:00</div><div id="label">Focus</div></div>
  </div>
  <div class="dots" id="dots" title="Focus rounds finished in this set of four"><i></i><i></i><i></i><i></i></div>
  <div class="row">
    <button class="main" id="toggle">Start</button>
    <button id="reset">Reset</button>
  </div>
  <div id="msg" aria-live="polite"></div>
  <div class="set">
    <label>Focus (min)<input type="number" id="m-focus" value="25" min="0.1" step="0.1"></label>
    <label>Short (min)<input type="number" id="m-short" value="5" min="0.1" step="0.1"></label>
    <label>Long (min)<input type="number" id="m-long" value="15" min="0.1" step="0.1"></label>
  </div>
  <label class="snd"><input type="checkbox" id="sound" checked> Beep when a round ends</label>
</div>

<script>
  const modes = {
    focus: { label: 'Focus', minutes: 25 },
    short: { label: 'Short break', minutes: 5 },
    long:  { label: 'Long break', minutes: 15 },
  };
  const $ = (id) => document.getElementById(id);
  let mode = 'focus';
  let done = 0;                    // focus rounds finished so far
  let total = 0, remaining = 0;    // milliseconds
  let endAt = null;                // end moment while running, null while paused
  let tick = null;
  let audio = null;

  const pad = (n) => String(n).padStart(2, '0');
  const fmt = (ms) => { const s = Math.ceil(ms / 1000); return pad(Math.floor(s / 60)) + ':' + pad(s % 60); };

  function paint() {
    if (endAt) remaining = Math.max(0, endAt - Date.now());   // the clock decides, not the tick count
    $('time').textContent = fmt(remaining);
    $('ring').style.strokeDashoffset = 100 * (1 - remaining / total);
    document.title = fmt(remaining) + ' ' + modes[mode].label;   // time shows in the browser tab
    if (endAt && remaining === 0) finish();
  }

  function start() {
    if (remaining === 0) return;
    if (!audio && window.AudioContext) audio = new AudioContext();   // made inside a click, so sound is allowed
    if (audio && audio.state === 'suspended') audio.resume();
    endAt = Date.now() + remaining;
    clearInterval(tick);
    tick = setInterval(paint, 250);
    $('toggle').textContent = 'Pause';
    $('msg').textContent = '';
  }

  function pause() {
    paint();
    endAt = null;
    clearInterval(tick);
    tick = null;
    $('toggle').textContent = remaining < total ? 'Resume' : 'Start';
  }

  function setMode(m) {
    pause();
    mode = m;
    total = remaining = modes[m].minutes * 60 * 1000;
    $('app').dataset.mode = m;
    $('label').textContent = modes[m].label;
    document.querySelectorAll('#tabs button').forEach((b) => b.setAttribute('aria-pressed', b.dataset.mode === m));
    const filled = done > 0 && done % 4 === 0 && m !== 'focus' ? 4 : done % 4;
    document.querySelectorAll('#dots i').forEach((d, i) => d.classList.toggle('on', i < filled));
    $('toggle').textContent = 'Start';
    paint();
  }

  function finish() {
    endAt = null;
    if ($('sound').checked) beep();
    if (mode === 'focus') {
      done++;
      setMode(done % 4 === 0 ? 'long' : 'short');
      $('msg').textContent = 'Focus round ' + done + ' done. Start your break.';
    } else {
      setMode('focus');
      $('msg').textContent = 'Break over. Ready for the next round.';
    }
  }

  function beep() {
    if (!audio) return;
    const osc = audio.createOscillator();
    const gain = audio.createGain();
    gain.gain.value = 0.15;
    osc.frequency.value = 880;
    osc.connect(gain);
    gain.connect(audio.destination);
    osc.start();
    osc.stop(audio.currentTime + 0.4);
  }

  $('toggle').addEventListener('click', () => (endAt ? pause() : start()));
  $('reset').addEventListener('click', () => setMode(mode));
  document.querySelectorAll('#tabs button').forEach((b) => b.addEventListener('click', () => setMode(b.dataset.mode)));
  ['focus', 'short', 'long'].forEach((m) => $('m-' + m).addEventListener('change', (e) => {
    const v = Number(e.target.value);
    if (!(v > 0)) { e.target.value = modes[m].minutes; return; }
    modes[m].minutes = v;
    if (m === mode && !endAt) setMode(mode);
  }));
  document.addEventListener('visibilitychange', paint);   // repaint the moment the tab comes back

  setMode('focus');
</script>
</body>
</html>
Pick a tab, press Start, and set your own minutes. Try 0.1 minutes in a box to see a round finish quickly.
  • Dial: an SVG circle with pathLength="100". The browser then treats the circle as 100 units long, so stroke-dashoffset can be a plain percentage.
  • Tab title: document.title sets the text of the browser tab, so you can read the time from another tab.
  • Steady digits: font-variant-numeric: tabular-nums gives every digit the same width, so the number does not wobble as it changes.
  • Repaint on return: a visibilitychange listener calls paint when the tab becomes visible again.

Adding a sound

Sound is the part most likely to go wrong. Starting audio with the Web Audio API outside a user action is subject to the browser's autoplay rules.

So the finished example creates its AudioContext inside the Start click, and plays a short beep from an oscillator when a round ends:

function beep() {
  const osc = audio.createOscillator();
  const gain = audio.createGain();
  gain.gain.value = 0.15;          // keep it quiet
  osc.frequency.value = 880;
  osc.connect(gain);
  gain.connect(audio.destination);
  osc.start();
  osc.stop(audio.currentTime + 0.4);
}

Play a sound in JavaScript covers sound files and the other ways to make a noise.

When it does not work

Symptom Cause Fix
A tick-counting timer runs twice as fast after two clicks on Start Each click started another interval, and both lower the counter Call clearInterval(tick) before starting a new one
The timer slowly falls behind It counts ticks Store endAt and read Date.now() on each repaint
Resume starts again from 25:00 Pause did not keep the time left Save remaining on pause, build a new endAt on resume
Seconds show as 5 instead of 05 The number is not padded Use padStart(2, '0')
The digits wobble as they change Digits have different widths Add font-variant-numeric: tabular-nums or a monospace font
No beep at the end Audio started without a user action Create the AudioContext inside the Start click
The beep is late when the tab is hidden Browsers can slow timers in background tabs Keep the tab visible, and repaint on visibilitychange
Nothing happens at all The script has an error See HTML JavaScript not working

A timer is easier to show than to describe. A screenshot cannot count down, and an .html attachment may open as plain code, or not at all, on a phone. Opening an HTML file on a phone covers why.

To send the working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to can press Start themselves. If you change the code later, the same link shows the new version.

Questions people ask

How do I make a pomodoro timer in JavaScript?

Save the moment the timer should end as Date.now() plus 25 minutes. Start a setInterval that, a few times a second, subtracts Date.now() from that moment and writes the minutes and seconds into the page. Call clearInterval when nothing is left.

How do I set a pomodoro timer?

The classic setup is a 25-minute work round, then a 5-minute break, and after every fourth round a longer break of 15 to 30 minutes. In the finished example on this page, type your own minutes into the three boxes under the dial.

Do I need to install a pomodoro timer?

Not if you build it as a web page. The code here uses only features built into the browser, so one .html file is the whole app. Open it in a browser, or put it behind a link and open that link on any device.

Why does my timer lose seconds?

It probably counts ticks, for example left = left - 1 inside setInterval. A tick that runs late, or never runs because the page was busy, is lost for good. Store the end time and read Date.now() on every repaint instead.

Does the timer keep going when the tab is in the background?

The time stays correct, because it is worked out from the clock on each repaint. What can change is how often the repaint runs. Browsers can slow timers in background tabs, so a beep may arrive a little late.

Keep reading