app
5-Minute Timer
Public UnreviewedA countdown timer that goes green to red, then flashes red and counts negative when time's up.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>5-Minute Timer</title>
<style>
:root {
--bg: #0d1117;
--panel: #161b22;
--text: #e6edf3;
--muted: #8b949e;
--border: #30363d;
}
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-user-select: none; user-select: none; }
html, body { height: 100%; }
body {
background: radial-gradient(ellipse at center, #11161f 0%, var(--bg) 70%);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex; flex-direction: column; align-items: center; justify-content: center;
min-height: 100%; gap: 40px; padding: 24px;
}
.timer { position: relative; width: min(78vw, 420px); aspect-ratio: 1; display: grid; place-items: center; }
.ring { position: absolute; inset: 0; width: 100%; height: 100%; transform: rotate(-90deg); }
.ring .track { fill: none; stroke: var(--border); stroke-width: 8; }
.ring .progress {
fill: none; stroke-width: 12; stroke-linecap: round;
transition: stroke 0.25s linear;
filter: drop-shadow(0 0 10px currentColor);
}
.readout { display: flex; flex-direction: column; align-items: center; gap: 6px; }
.time {
font-variant-numeric: tabular-nums;
font-family: "SF Mono", "JetBrains Mono", ui-monospace, Menlo, Consolas, monospace;
font-size: clamp(48px, 17vw, 96px); font-weight: 700; line-height: 1;
letter-spacing: 1px; transition: color 0.25s linear;
}
.label { font-size: 13px; letter-spacing: 2px; text-transform: uppercase; color: var(--muted); }
/* Flash the WHOLE background red when expired (past zero) */
body.expired { animation: bgFlash 1s ease-in-out infinite; } /* 1 Hz */
@keyframes bgFlash {
0%, 100% { background: var(--bg); }
50% { background: #c01f28; }
}
.controls { display: flex; gap: 14px; }
button {
font-family: inherit; font-size: 15px; font-weight: 600;
color: var(--text); background: var(--panel);
border: 1px solid var(--border); border-radius: 10px;
padding: 12px 22px; min-width: 110px; cursor: pointer;
transition: background 0.15s, border-color 0.15s, transform 0.05s;
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
}
button:hover { background: #1f2630; border-color: #484f58; }
button:active { transform: translateY(1px); }
button.primary { background: #1f6feb22; border-color: #1f6feb88; color: #cfe2ff; }
button.primary:hover { background: #1f6feb33; }
button svg { width: 16px; height: 16px; fill: currentColor; }
.adjust { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; max-width: 430px; }
.adj { min-width: 58px; padding: 9px 12px; font-size: 14px; }
.adj[data-delta^="-"] { color: #ffc2c2; border-color: #5a2a2a; }
.adj[data-delta^="-"]:hover { background: #261313; border-color: #7a3a3a; }
.adj:not([data-delta^="-"]) { color: #bdf0cd; border-color: #2c5a3e; }
.adj:not([data-delta^="-"]):hover { background: #16271c; border-color: #3c7a52; }
</style>
</head>
<body>
<div class="timer" id="timer">
<svg class="ring" viewBox="0 0 240 240">
<circle class="track" cx="120" cy="120" r="110"></circle>
<circle class="progress" id="progress" cx="120" cy="120" r="110"></circle>
</svg>
<div class="readout">
<div class="time" id="time">5:00</div>
<div class="label" id="label">ready</div>
</div>
</div>
<div class="controls">
<button class="primary" id="playBtn" aria-label="Play or pause">
<svg id="playIcon" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
<span id="playLabel">Start</span>
</button>
<button id="resetBtn" aria-label="Reset">
<svg viewBox="0 0 24 24"><path d="M12 5V1L7 6l5 5V7a5 5 0 1 1-5 5H5a7 7 0 1 0 7-7z"/></svg>
<span>Reset</span>
</button>
</div>
<div class="adjust" id="adjust">
<button class="adj" data-delta="-60">−60s</button>
<button class="adj" data-delta="-30">−30s</button>
<button class="adj" data-delta="-10">−10s</button>
<button class="adj" data-delta="10">+10s</button>
<button class="adj" data-delta="30">+30s</button>
<button class="adj" data-delta="60">+60s</button>
</div>
<script>
(function () {
const TOTAL = 5 * 60; // 5 minutes, seconds
const R = 110;
const CIRC = 2 * Math.PI * R;
const $timer = document.getElementById('timer');
const $time = document.getElementById('time');
const $label = document.getElementById('label');
const $progress = document.getElementById('progress');
const $playBtn = document.getElementById('playBtn');
const $playIcon = document.getElementById('playIcon');
const $playLabel = document.getElementById('playLabel');
const $resetBtn = document.getElementById('resetBtn');
$progress.style.strokeDasharray = CIRC;
const ICON_PLAY = 'M8 5v14l11-7z';
const ICON_PAUSE = 'M6 5h4v14H6zM14 5h4v14h-4z';
let remaining = TOTAL; // seconds, may go negative
let running = false;
let endAt = 0; // performance.now()/1000 + remaining, while running
function now() { return performance.now() / 1000; }
// green (120°) at full time -> red (0°) at zero
function colorFor(r) {
if (r > 0) {
const hue = 120 * Math.max(0, Math.min(1, r / TOTAL));
return `hsl(${hue.toFixed(1)}, 85%, 55%)`;
}
return 'hsl(0, 90%, 58%)';
}
function fmt(r) {
let neg = false, secs;
if (r > 0) { secs = Math.ceil(r - 1e-9); }
else { secs = Math.ceil(-r - 1e-9); neg = secs > 0; }
const m = Math.floor(secs / 60);
const s = secs % 60;
return (neg ? '-' : '') + m + ':' + String(s).padStart(2, '0');
}
function render() {
const expired = remaining <= 0;
const flashing = expired && running; // only strobe while actually counting past 0
const color = colorFor(remaining);
$time.textContent = fmt(remaining);
$time.style.color = flashing ? '#ffffff' : color; // white over the strobe; red when paused-expired
$progress.style.stroke = color;
// ring: full at TOTAL, empty at <=0
const frac = Math.max(0, Math.min(1, remaining / TOTAL));
$progress.style.strokeDashoffset = CIRC * (1 - frac);
$timer.classList.toggle('expired', expired);
document.body.classList.toggle('expired', flashing); // strobe stops when paused
$label.textContent = expired ? 'time up' : (running ? 'running' : (remaining === TOTAL ? 'ready' : 'paused'));
}
function tick() {
if (running) remaining = endAt - now();
render();
requestAnimationFrame(tick);
}
function setPlayingUI(isRunning) {
$playIcon.querySelector('path').setAttribute('d', isRunning ? ICON_PAUSE : ICON_PLAY);
$playLabel.textContent = isRunning ? 'Pause' : (remaining === TOTAL ? 'Start' : 'Resume');
}
function play() {
if (running) return;
endAt = now() + remaining;
running = true;
setPlayingUI(true);
}
function pause() {
if (!running) return;
remaining = endAt - now();
running = false;
setPlayingUI(false);
}
function toggle() { running ? pause() : play(); }
function reset() {
running = false;
remaining = TOTAL;
setPlayingUI(false);
render();
}
function adjust(delta) {
if (running) { endAt += delta; remaining = endAt - now(); }
else { remaining += delta; }
setPlayingUI(running); // refresh Start/Resume label
render();
}
$playBtn.addEventListener('click', toggle);
$resetBtn.addEventListener('click', reset);
document.querySelectorAll('.adj').forEach(b => {
b.addEventListener('click', () => adjust(parseInt(b.dataset.delta, 10)));
});
document.addEventListener('keydown', (e) => {
if (e.code === 'Space') { e.preventDefault(); toggle(); }
else if (e.key === 'r' || e.key === 'R') { reset(); }
});
setPlayingUI(false);
requestAnimationFrame(tick);
})();
</script>
</body>
</html>