Countdown Timer — Free HTML CSS JS Pomodoro Snippet
Countdown Timer · Loaders · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="wrap">
<div class="timer-card">
<div class="ring-wrap">
<svg class="ring-svg" viewBox="0 0 120 120">
<circle class="ring-bg" cx="60" cy="60" r="52"/>
<circle class="ring-fill" cx="60" cy="60" r="52" id="ring"/>
</svg>
<div class="ring-inner">
<div class="time-num" id="time-num">25:00</div>
<div class="time-lbl" id="time-lbl">Focus</div>
</div>
</div>
<div class="mode-tabs">
<button class="mode active" onclick="setMode('focus',25)" id="btn-focus">Focus</button>
<button class="mode" onclick="setMode('break',5)" id="btn-break">Short break</button>
<button class="mode" onclick="setMode('long',15)" id="btn-long">Long break</button>
</div>
<div class="controls">
<button class="ctrl-btn" id="main-btn" onclick="toggle()">Start</button>
<button class="ctrl-btn secondary" onclick="reset()">Reset</button>
</div>
<div class="session-row">
Session <strong id="session-num">1</strong> of 4
<div class="dots" id="dots">
<span class="dot"></span><span class="dot"></span><span class="dot"></span><span class="dot"></span>
</div>
</div>
</div>
</div>* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f8fafc; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
.wrap { width: 100%; max-width: 320px; }
.timer-card { background: #fff; border-radius: 24px; padding: 32px 24px; box-shadow: 0 4px 24px rgba(0,0,0,0.08); border: 1px solid #e2e8f0; display: flex; flex-direction: column; align-items: center; gap: 24px; }
.ring-wrap { position: relative; width: 160px; height: 160px; }
.ring-svg { width: 100%; height: 100%; transform: rotate(-90deg); }
.ring-bg { fill: none; stroke: #f1f5f9; stroke-width: 8; }
.ring-fill { fill: none; stroke: #6366f1; stroke-width: 8; stroke-linecap: round;
stroke-dasharray: 327; stroke-dashoffset: 0; transition: stroke-dashoffset 0.5s linear, stroke 0.3s; }
.ring-inner { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; }
.time-num { font-size: 32px; font-weight: 800; color: #0f172a; font-variant-numeric: tabular-nums; line-height: 1; }
.time-lbl { font-size: 11px; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.8px; }
.mode-tabs { display: flex; gap: 4px; background: #f1f5f9; border-radius: 10px; padding: 3px; }
.mode { background: transparent; border: none; font-size: 12px; font-weight: 600; color: #64748b; padding: 6px 12px; border-radius: 8px; cursor: pointer; transition: all 0.15s; white-space: nowrap; }
.mode.active { background: #fff; color: #6366f1; box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
.controls { display: flex; gap: 10px; width: 100%; }
.ctrl-btn { flex: 1; padding: 12px; border: none; border-radius: 10px; font-size: 14px; font-weight: 700; cursor: pointer; transition: all 0.15s; }
.ctrl-btn:not(.secondary) { background: #6366f1; color: #fff; }
.ctrl-btn:not(.secondary):hover { background: #4f46e5; }
.ctrl-btn.secondary { background: #f1f5f9; color: #475569; }
.ctrl-btn.secondary:hover { background: #e2e8f0; }
.session-row { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #94a3b8; }
.dots { display: flex; gap: 5px; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: #e2e8f0; transition: background 0.3s; }
.dot.done { background: #6366f1; }const C = 2 * Math.PI * 52; // circumference
const modes = { focus:25, break:5, long:15 };
let currentMode = 'focus';
let totalSecs = 25 * 60;
let remaining = totalSecs;
let running = false;
let session = 1;
let ticker = null;
function setMode(mode, mins) {
clearInterval(ticker);
running = false;
currentMode = mode;
totalSecs = mins * 60;
remaining = totalSecs;
document.querySelectorAll('.mode').forEach(b => b.classList.remove('active'));
document.getElementById('btn-' + mode).classList.add('active');
document.getElementById('time-lbl').textContent = mode === 'focus' ? 'Focus' : mode === 'break' ? 'Short break' : 'Long break';
document.getElementById('main-btn').textContent = 'Start';
document.getElementById('ring').style.stroke = mode === 'focus' ? '#6366f1' : mode === 'break' ? '#10b981' : '#0ea5e9';
render();
}
function toggle() {
if (running) {
clearInterval(ticker);
running = false;
document.getElementById('main-btn').textContent = 'Resume';
} else {
running = true;
document.getElementById('main-btn').textContent = 'Pause';
ticker = setInterval(() => {
if (remaining <= 0) {
clearInterval(ticker);
running = false;
document.getElementById('main-btn').textContent = 'Start';
if (currentMode === 'focus') {
session = Math.min(4, session + 1);
document.getElementById('session-num').textContent = session;
updateDots();
}
return;
}
remaining--;
render();
}, 1000);
}
}
function reset() {
clearInterval(ticker);
running = false;
remaining = totalSecs;
document.getElementById('main-btn').textContent = 'Start';
render();
}
function render() {
const m = Math.floor(remaining / 60);
const s = remaining % 60;
document.getElementById('time-num').textContent = String(m).padStart(2,'0') + ':' + String(s).padStart(2,'0');
const pct = remaining / totalSecs;
document.getElementById('ring').style.strokeDashoffset = C * (1 - pct);
}
function updateDots() {
document.querySelectorAll('.dot').forEach((d, i) => d.classList.toggle('done', i < session - 1));
}
render();Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month, 7 days free), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Countdown Timer — SVG Ring Progress, Pomodoro Modes, Session Dots & Pause/Resume

A countdown timer with a circular progress ring is one of the most visually engaging loading and progress components you can build without a library. This snippet implements a complete Pomodoro-style countdown timer: an SVG stroke-dashoffset ring that drains as time passes, three mode tabs (Focus 25min, Short break 5min, Long break 15min), Start/Pause/Resume/Reset controls, a session counter with dot indicators, and a colour change per mode — all in plain HTML, CSS, and vanilla JavaScript.
How the SVG ring countdown works
The ring uses the stroke-dashoffset technique. The circle has a circumference C = 2 × π × radius = 2 × π × 52 ≈ 327px. stroke-dasharray: 327 makes the stroke one continuous dash equal to the full circumference. stroke-dashoffset controls how much of the dash is hidden — setting it to 327 hides the full ring (empty), 0 shows the full ring (complete). As time passes: dashoffset = C × (1 - remaining/total). The CSS transition: stroke-dashoffset 0.5s linear smooths the movement each second.
The Pomodoro mode tabs
Three tabs switch between Focus (25 min, indigo ring), Short break (5 min, green ring), and Long break (15 min, cyan ring). Switching a mode resets the timer, updates the stroke colour via ring.style.stroke, and updates the centre label. The mode button active state uses the same pill tab pattern as the calendar widget.
Start/Pause/Resume logic
A setInterval fires every 1000ms when running. The toggle() function checks the running boolean: if running, it clears the interval, sets running = false, and changes the button to "Resume". If not running, it starts the interval and changes the button to "Pause". The Reset button clears the interval and restores remaining to totalSecs.
Session dot tracker
Four dot indicators track completed focus sessions. Each completed focus session (when the ring reaches 0 in Focus mode) fills one dot with the accent colour and increments the session counter. After 4 sessions, the counter stops at 4 — a visual cue to take a long break.
Tabular number digits
The time display uses font-variant-numeric: tabular-nums — a CSS OpenType feature that makes all digits the same width, preventing the display from shifting width as the time counts down.
Step by step
How to Use
- 1Click Start to begin the countdownThe SVG ring starts draining and the timer counts down. Click Pause to pause mid-session. Click Resume to continue. The button label changes to reflect the current state.
- 2Switch between Focus and break modesClick Focus (25 min), Short break (5 min), or Long break (15 min) to switch modes. The ring colour changes: indigo for Focus, green for Short break, cyan for Long break. Switching resets the timer.
- 3Change the timer durationsIn the JS panel, update the modes object: { focus: N, break: N, long: N } where N is the duration in minutes. Change the onclick values in the HTML mode buttons to match the new durations.
- 4Customize ring coloursIn the JS setMode() function, update the ring.style.stroke values per mode. In the CSS, update .ring-fill stroke and .ctrl-btn background to change the default accent colour.
- 5Add a sound notification on timer endInside the ticker interval, when remaining <= 0, add: const audio = new Audio("bell.mp3"); audio.play(). Or use the Web Audio API to generate a tone: const ctx = new AudioContext(); const osc = ctx.createOscillator(); osc.connect(ctx.destination); osc.start(); setTimeout(() => osc.stop(), 400).
- 6Export in your formatClick "HTML" for a standalone file, "JSX" for a React component using useState for remaining, useEffect for the interval with cleanup, or "Tailwind" for a Tailwind version.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
The ring uses stroke-dasharray: 327 (the full circumference, computed as 2×π×52) and stroke-dashoffset. When dashoffset = 0, the full ring is visible. When dashoffset = 327, the ring is completely hidden. The formula dashoffset = C × (1 - remaining/total) maps the remaining time fraction to the dashoffset: at full time remaining (remaining/total = 1), dashoffset = 0 (full ring). At time expired (remaining/total = 0), dashoffset = C (empty ring). CSS transition: stroke-dashoffset 0.5s linear smooths the change each second.
In the setInterval callback, when remaining <= 0, use the Web Audio API: const ctx = new (window.AudioContext || window.webkitAudioContext)(); const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.connect(gain); gain.connect(ctx.destination); osc.frequency.value = 440; gain.gain.setValueAtTime(0.3, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.8); osc.start(); osc.stop(ctx.currentTime + 0.8). This creates a 440Hz tone that fades out over 0.8 seconds without any audio file.
Inside the ticker setInterval, after decrementing remaining, add a colour warning: if (remaining <= 60 && currentMode === "focus") { document.getElementById("ring").style.stroke = "#ef4444"; } else if (remaining <= 0) { document.getElementById("ring").style.stroke = ""; }. The CSS transition on the stroke property (add transition: stroke 0.3s to .ring-fill) will animate the colour change smoothly.
Click "JSX" to download. Manage remaining, running, currentMode, and session in useState. Run the interval in useEffect: useEffect(() => { if (!running) return; const id = setInterval(() => setRemaining(r => r - 1), 1000); return () => clearInterval(id); }, [running]). Use useEffect to check if remaining reaches 0: useEffect(() => { if (remaining <= 0) { setRunning(false); if (currentMode === "focus") setSession(s => Math.min(4, s+1)); } }, [remaining]).