DEV Community

htmlput
htmlput

Posted on

A Pomodoro timer in 20 lines of vanilla JS

Most first JavaScript projects die in setup. You pick a framework, install a toolchain, and by the time anything appears on screen you've forgotten what you wanted to learn.

A Pomodoro timer is the opposite. It needs exactly three things: a heartbeat, a piece of state, and a screen. That's it. No libraries, no build step, no backend. One HTML file, a handful of lines of JavaScript, and you end up understanding the core loop behind nearly every interactive UI ever built.

The three moving parts

1. setInterval is the heartbeat. The entire timer is this line:

timer = setInterval(tick, 1000);
Enter fullscreen mode Exit fullscreen mode

Every second, the browser calls tick(). Everything else is decoration around that one repeating pulse.

2. One variable is the entire state. let left = FOCUS — seconds remaining. 25 x 60 = 1500. There is no state object, no store, no reducer. The timer's whole memory is a single number counting down. When you're learning, that's the point: state doesn't have to be architecture. It can be a number.

3. textContent is the render. A fmt helper turns seconds into MM:SS with padStart, and tick writes it to the page:

el.textContent = fmt(left);
Enter fullscreen mode Exit fullscreen mode

No virtual DOM, no diffing — you compute the string, you set the string. Every framework's render cycle is a fancier version of this exact idea.

The pause/resume trick

Here's my favorite part. Look at the start button handler:

btn.onclick = () => {
  if (timer) { clearInterval(timer); timer = null; btn.textContent = 'Resume'; return; }
  if (left <= 0) left = FOCUS;
  timer = setInterval(tick, 1000); btn.textContent = 'Pause';
};
Enter fullscreen mode Exit fullscreen mode

Pausing isn't a mode. It's the absence of the interval. timer is either an interval ID (running) or null (paused), so the variable doubles as the paused flag. Beginners often reach for an isPaused boolean and then spend an hour keeping it in sync with reality. The trick: make the state be the reality. If there is no interval, you're paused. Nothing to sync, nothing to drift.

Reset is the same idea in reverse: clear the interval, put 1500 back into left, redraw.

The full file

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pomodoro — 25 min focus</title>
<style>
  body{font-family:system-ui;display:grid;place-items:center;min-height:100vh;margin:0;background:#0f172a;color:#f1f5f9}
  #time{font-size:5rem;font-variant-numeric:tabular-nums;margin:0 0 1rem}
  button{font-size:1rem;padding:.6rem 1.4rem;margin:.3rem;border:0;border-radius:999px;cursor:pointer}
  #start{background:#22c55e;color:#052e16}#reset{background:#334155;color:#e2e8f0}
</style>
</head>
<body>
<div style="text-align:center">
  <p id="time">25:00</p>
  <button id="start">Start</button>
  <button id="reset">Reset</button>
</div>
<script>
const FOCUS = 25 * 60;
let left = FOCUS, timer = null;
const el = document.getElementById('time');
const btn = document.getElementById('start');
const fmt = s => `${String(Math.floor(s/60)).padStart(2,'0')}:${String(s%60).padStart(2,'0')}`;
const tick = () => { el.textContent = fmt(left); if (--left < 0) { clearInterval(timer); timer = null; btn.textContent = 'Start'; alert("Time's up — take a break!"); } };
btn.onclick = () => {
  if (timer) { clearInterval(timer); timer = null; btn.textContent = 'Resume'; return; }
  if (left <= 0) left = FOCUS;
  timer = setInterval(tick, 1000); btn.textContent = 'Pause';
};
document.getElementById('reset').onclick = () => { clearInterval(timer); timer = null; left = FOCUS; el.textContent = fmt(left); btn.textContent = 'Start'; };
</script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Copy it into a file called pomodoro.html, open that file in your browser, and you have a working focus timer. No server, no install, nothing to configure.

Fork it in 30 seconds: change one constant — const FOCUS = 5 * 60; — and you get a 5-minute test run instead of waiting 25 minutes to see the "Time's up" alert. Edit, refresh, observe: the fastest feedback loop in programming.

What to build next

This timer is a seed. Each natural upgrade teaches one new concept:

  • Break mode — alternate 25-minute focus blocks with 5-minute breaks. Teaches you state machines (a mode variable: 'focus' or 'break').
  • A sound when time's up — swap the alert for the Web Audio API. Teaches you browser APIs beyond the DOM.
  • A streak counter in localStorage — persist completed sessions across reloads. Teaches you the only browser database most small projects ever need.

Pick one, and the 20-line timer becomes a real app — still in a single file.

Top comments (1)

Collapse
 
jonrandy profile image
Jon Randy 🎖️ •

If you're going for small - why bother with the getElementById calls? You don't need those.