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.
What You'll Learn
- How SVG circles work and how
stroke-dasharray+stroke-dashoffsetcreate an animated progress ring - How CSS custom properties (
--accent) let JavaScript re-theme an entire UI in one line - How
setIntervalandclearIntervalpower 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
<!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
: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
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 ProjectSingle HTML file · All code included