Back to Projects Intermediate

Pomodoro Timer

Build the world's most popular productivity timer. A glowing SVG ring counts down 25-minute focus sessions and switches modes for short and long breaks - with the accent colour changing each time.

HTML, CSS, JavaScript ~1-2 hours to build Free source code

What You'll Learn

  • How SVG circles work and how stroke-dasharray + stroke-dashoffset create an animated progress ring
  • How CSS custom properties (--accent) let JavaScript re-theme an entire UI in one line
  • How setInterval and clearInterval power a precise countdown timer
  • How to manage multiple application states (idle, running, paused, different modes)
  • How to keep track of sessions and total focus time as the user works

How It Works

The SVG progress ring

An SVG <circle> with radius 52 has a circumference of about 326.7px. Setting stroke-dasharray to that number and reducing stroke-dashoffset from the full circumference to 0 draws the ring from empty to full - like a circular progress bar.

Dynamic theming with CSS variables

One CSS variable --accent controls the ring colour, button colour, and text highlights. When the user switches mode, a single document.documentElement.style.setProperty('--accent', color) call re-themes the entire UI instantly - no class toggling needed.

Timer state machine

The timer is either idle, running, or paused. Clicking Start creates a new setInterval that fires every second; clicking Pause calls clearInterval to freeze it. Switching mode always resets to idle so there is never more than one interval running at once.

Source Code

HTML Structure

HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Pomodoro Timer</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <div class="pomo">
    <h1 class="app-title">🍅 Pomodoro Timer</h1>
    <div class="tabs">
      <button class="tab active" data-mode="work">Pomodoro</button>
      <button class="tab" data-mode="short">Short Break</button>
      <button class="tab" data-mode="long">Long Break</button>
    </div>
    <div class="ring-wrap">
      <svg viewBox="0 0 120 120" class="ring">
        <circle class="ring-track" cx="60" cy="60" r="52"/>
        <circle class="ring-fill" id="ringFill" cx="60" cy="60" r="52"/>
      </svg>
      <div class="ring-inner">
        <div class="time" id="time">25:00</div>
        <div class="status" id="status">Focus</div>
      </div>
    </div>
    <div class="btns">
      <button class="btn-start" id="startBtn">▶ Start</button>
      <button class="btn-reset" id="resetBtn">↻ Reset</button>
    </div>
    <div class="info">
      <div class="info-item">
        <span class="info-label">Sessions</span>
        <span class="info-val" id="sessions">0</span>
      </div>
      <div class="info-item">
        <span class="info-label">Today</span>
        <span class="info-val"><span id="focusTime">0</span>m</span>
      </div>
    </div>
  </div>
  <script src="script.js"></script>
</body>
</html>

CSS Styling

CSS
:root { --accent: #ff6b6b; }
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
body {
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #0f0e17;
  font-family: 'Segoe UI', system-ui, sans-serif;
  padding: 24px;
}
.pomo { text-align: center; width: 100%; max-width: 340px; }
.app-title {
  font-size: 1.2rem;
  font-weight: 800;
  color: rgba(255,255,255,0.9);
  margin-bottom: 28px;
  letter-spacing: 0.5px;
}
.tabs {
  display: flex;
  gap: 6px;
  background: rgba(255,255,255,0.05);
  padding: 5px;
  border-radius: 12px;
  margin-bottom: 36px;
}
.tab {
  flex: 1;
  padding: 8px 10px;
  border: none;
  border-radius: 8px;
  font-size: 0.78rem;
  font-weight: 600;
  cursor: pointer;
  background: transparent;
  color: rgba(255,255,255,0.4);
  transition: all 0.3s;
  white-space: nowrap;
}
.tab.active {
  background: var(--accent);
  color: #fff;
  box-shadow: 0 2px 12px rgba(0,0,0,0.3);
}
.ring-wrap {
  position: relative;
  width: 230px;
  height: 230px;
  margin: 0 auto 32px;
}
.ring { width: 100%; height: 100%; transform: rotate(-90deg); }
.ring-track { fill: none; stroke: rgba(255,255,255,0.06); stroke-width: 7; }
.ring-fill {
  fill: none;
  stroke: var(--accent);
  stroke-width: 7;
  stroke-linecap: round;
  stroke-dasharray: 326.7;
  stroke-dashoffset: 0;
  transition: stroke-dashoffset 1s linear, stroke 0.4s;
  filter: drop-shadow(0 0 8px var(--accent));
}
.ring-inner {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}
.time {
  font-size: 3.4rem;
  font-weight: 800;
  color: #fff;
  letter-spacing: -2px;
  font-variant-numeric: tabular-nums;
  line-height: 1;
}
.status {
  font-size: 0.82rem;
  color: rgba(255,255,255,0.45);
  margin-top: 8px;
  letter-spacing: 1px;
  text-transform: uppercase;
}
.btns { display: flex; gap: 12px; justify-content: center; margin-bottom: 28px; }
.btn-start {
  padding: 14px 44px;
  border: none;
  border-radius: 50px;
  font-size: 1rem;
  font-weight: 700;
  cursor: pointer;
  background: var(--accent);
  color: #fff;
  transition: all 0.2s;
  box-shadow: 0 4px 20px rgba(0,0,0,0.3);
}
.btn-start:hover { filter: brightness(1.1); transform: translateY(-2px); }
.btn-start:active { transform: translateY(0); }
.btn-reset {
  padding: 14px 24px;
  border: none;
  border-radius: 50px;
  font-size: 1rem;
  font-weight: 700;
  cursor: pointer;
  background: rgba(255,255,255,0.08);
  color: rgba(255,255,255,0.5);
  transition: all 0.2s;
}
.btn-reset:hover { background: rgba(255,255,255,0.14); color: #fff; }
.info { display: flex; justify-content: center; gap: 32px; }
.info-item { display: flex; flex-direction: column; gap: 4px; }
.info-label {
  font-size: 0.7rem;
  color: rgba(255,255,255,0.3);
  text-transform: uppercase;
  letter-spacing: 1px;
}
.info-val { font-size: 1.1rem; font-weight: 700; color: var(--accent); }

JavaScript Logic

JavaScript
const MODES = {
  work:  { duration: 25 * 60, label: 'Focus',       accent: '#ff6b6b' },
  short: { duration:  5 * 60, label: 'Short Break', accent: '#51cf66' },
  long:  { duration: 15 * 60, label: 'Long Break',  accent: '#339af0' }
};
const CIRC = 326.7; // 2 * Math.PI * 52 (SVG circle radius)

let mode = 'work';
let timeLeft = MODES.work.duration;
let isRunning = false;
let ticker = null;
let sessions = 0;
let focusMinutes = 0;

const timeEl     = document.getElementById('time');
const statusEl   = document.getElementById('status');
const ringFill   = document.getElementById('ringFill');
const startBtn   = document.getElementById('startBtn');
const sessionsEl = document.getElementById('sessions');
const focusEl    = document.getElementById('focusTime');

function setAccent(color) {
  document.documentElement.style.setProperty('--accent', color);
}

function setMode(m) {
  clearInterval(ticker);
  isRunning = false;
  startBtn.textContent = '? Start';
  mode = m;
  timeLeft = MODES[m].duration;
  statusEl.textContent = MODES[m].label;
  setAccent(MODES[m].accent);
  document.querySelectorAll('.tab').forEach(function(t) {
    t.classList.toggle('active', t.dataset.mode === m);
  });
  render();
}

function render() {
  var m = Math.floor(timeLeft / 60).toString().padStart(2, '0');
  var s = (timeLeft % 60).toString().padStart(2, '0');
  timeEl.textContent = m + ':' + s;
  var progress = timeLeft / MODES[mode].duration;
  ringFill.style.strokeDashoffset = CIRC * (1 - progress);
}

function tick() {
  if (timeLeft <= 0) {
    clearInterval(ticker);
    isRunning = false;
    startBtn.textContent = '? Start';
    if (mode === 'work') {
      sessions++;
      focusMinutes += 25;
      sessionsEl.textContent = sessions;
      focusEl.textContent = focusMinutes;
    }
    return;
  }
  timeLeft--;
  render();
}

startBtn.addEventListener('click', function() {
  if (isRunning) {
    clearInterval(ticker);
    isRunning = false;
    startBtn.textContent = '? Start';
  } else {
    ticker = setInterval(tick, 1000);
    isRunning = true;
    startBtn.textContent = '? Pause';
  }
});

document.getElementById('resetBtn').addEventListener('click', function() {
  setMode(mode);
});

document.querySelectorAll('.tab').forEach(function(tab) {
  tab.addEventListener('click', function() {
    setMode(this.dataset.mode);
  });
});

// Initialize
setAccent(MODES.work.accent);
render();

Download Source Code

Single ready-to-run HTML file. Open it in any browser instantly!

Download Project

Single HTML file · All code included