A beautiful browser Pomodoro timer with a circular progress ring, work/break mode switching, session counter, and a notification alert.
The circular timer uses an SVG circle with a stroke-dashoffset animation.
<!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>
<?php require_once __DIR__ . '/../promo_banner.php'; ?>
<div class="app">
<h1>🍅 Pomodoro Timer</h1>
<div class="mode-btns">
<button class="mode-btn active" onclick="setMode('work')">Work</button>
<button class="mode-btn" onclick="setMode('short')">Short Break</button>
<button class="mode-btn" onclick="setMode('long')">Long Break</button>
</div>
<div class="timer-ring">
<svg width="220" height="220" viewBox="0 0 220 220">
<circle class="ring-bg" cx="110" cy="110" r="96" />
<circle class="ring-fill" cx="110" cy="110" r="96" id="ring" />
</svg>
<div class="timer-display" id="display">25:00</div>
</div>
<div class="controls">
<button id="startBtn" onclick="toggle()">Start</button>
<button onclick="reset()">Reset</button>
</div>
<div class="sessions">Sessions completed: <b id="sessionCount">0</b></div>
</div>
<script src="app.js"></script>
</body>
</html>
The ring animates by changing stroke-dashoffset on the SVG circle.
/* style.css */
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:'Segoe UI',sans-serif; background:#0f172a; color:#fff; min-height:100vh; display:flex; align-items:center; justify-content:center; }
.app { text-align:center; }
h1 { font-size:1.6rem; margin-bottom:24px; }
.mode-btns { display:flex; gap:10px; justify-content:center; margin-bottom:32px; }
.mode-btn { padding:8px 18px; border:2px solid #334155; background:none; color:#94a3b8; border-radius:20px; cursor:pointer; font-size:0.85rem; transition:all 0.2s; }
.mode-btn.active { background:#e74c3c; border-color:#e74c3c; color:#fff; font-weight:700; }
.timer-ring { position:relative; width:220px; height:220px; margin:0 auto 32px; }
.ring-bg { fill:none; stroke:#1e293b; stroke-width:12; }
.ring-fill { fill:none; stroke:#e74c3c; stroke-width:12; stroke-linecap:round;
stroke-dasharray: 603; stroke-dashoffset: 0;
transform:rotate(-90deg); transform-origin:50% 50%;
transition: stroke-dashoffset 1s linear, stroke 0.3s; }
.ring-fill.break { stroke:#22c55e; }
.timer-display { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); font-size:3rem; font-weight:800; font-variant-numeric:tabular-nums; }
.controls { display:flex; gap:16px; justify-content:center; margin-bottom:20px; }
.controls button { padding:12px 36px; border:none; border-radius:10px; font-size:1rem; font-weight:700; cursor:pointer; }
#startBtn { background:#e74c3c; color:#fff; }
.controls button:last-child { background:#1e293b; color:#94a3b8; }
.sessions { color:#64748b; font-size:0.88rem; }
Track elapsed time, update the display, and animate the ring.
// app.js
const MODES = { work:25*60, short:5*60, long:15*60 };
const CIRCUMFERENCE = 2 * Math.PI * 96; // ~603
let currentMode = 'work';
let totalSecs, remaining, interval, running = false, sessions = 0;
function setMode(mode) {
clearInterval(interval); running = false;
document.getElementById('startBtn').textContent = 'Start';
document.querySelectorAll('.mode-btn').forEach((b,i) =>
b.classList.toggle('active', ['work','short','long'][i] === mode));
const ring = document.getElementById('ring');
ring.classList.toggle('break', mode !== 'work');
document.getElementById('startBtn').style.background = mode === 'work' ? '#e74c3c' : '#22c55e';
currentMode = mode;
totalSecs = remaining = MODES[mode];
updateDisplay();
updateRing(1);
}
function updateDisplay() {
const m = Math.floor(remaining / 60);
const s = remaining % 60;
document.getElementById('display').textContent =
`${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
document.title = `${document.getElementById('display').textContent} — Pomodoro`;
}
function updateRing(fraction) {
const offset = CIRCUMFERENCE * (1 - fraction);
document.getElementById('ring').style.strokeDashoffset = offset;
}
function toggle() {
if (running) {
clearInterval(interval); running = false;
document.getElementById('startBtn').textContent = 'Resume';
} else {
running = true;
document.getElementById('startBtn').textContent = 'Pause';
interval = setInterval(() => {
remaining--;
updateDisplay();
updateRing(remaining / totalSecs);
if (remaining <= 0) {
clearInterval(interval); running = false;
if (currentMode === 'work') {
sessions++;
document.getElementById('sessionCount').textContent = sessions;
}
notify(currentMode === 'work' ? 'Work session done! Take a break.' : 'Break over! Time to work.');
}
}, 1000);
}
}
function reset() {
clearInterval(interval); running = false;
document.getElementById('startBtn').textContent = 'Start';
remaining = MODES[currentMode];
updateDisplay(); updateRing(1);
}
function notify(msg) {
if (Notification.permission === 'granted') {
new Notification('🍅 Pomodoro', { body: msg });
} else {
alert(msg);
}
}
// Request notification permission on load
if (Notification.permission === 'default') Notification.requestPermission();
setMode('work');
A polished productivity tool with zero dependencies. The same SVG ring technique is used for loading spinners, progress bars, and skill charts on CVs.
Spotted a bug, broken code, or something that doesn't look right? Tell us what's off and we'll fix it.