.
Pro Chronometer is a free, copy-paste ready professional stopwatch built
with pure HTML, CSS, and vanilla JavaScript — zero external libraries. It uses
Date.now() drift correction for accuracy: on start,
startTime = Date.now() - elapsed, then each tick:
elapsed = Date.now() - startTime. This avoids the progressive drift that
accumulates when incrementing a counter. The setInterval runs every
10ms for smooth centisecond updates. Time is formatted
using new Date(ms).getUTCMinutes(), getUTCSeconds(), and
Math.floor(getUTCMilliseconds()/10) with padStart(2,'0')
for consistent MM:SS.CC display.
Each lap records { num, total, split } where
split = elapsed - lastLapTime. After recording, lastLapTime = total
resets the interval reference. The fastest lap receives a green
.fastest highlight class (background rgba(63,185,80,.15), green left border);
the slowest gets a red .slowest class — both calculated by
comparing the new split against previous splits via Math.min() and
Math.max(). Live statistics update on every lap: best, worst, and average
split times. CSV export uses the Blob API:
new Blob([csv], {type: 'text/csv'}) with URL.createObjectURL
and a programmatic anchor click — no server, no library.
The key insight: setInterval does not fire with perfect 10ms spacing —
tab throttling, garbage collection, and CPU load cause drift. The fix is to always
recalculate from a fixed reference: startTime = Date.now() - elapsed
on start (preserving any previously accumulated elapsed time for resume support).
Then every interval tick: elapsed = Date.now() - startTime. This means
the displayed time always reflects real wall-clock duration, not an accumulated
counter. The timer stays accurate over long sessions — a 5-minute run will show
exactly 5:00.00 on stop.
Each action plays a different tone: Start = 600Hz (warm mid tone),
Stop = 400Hz (lower, calmer), Lap = 1000Hz (bright,
crisp). The beep function: new AudioContext() → createOscillator()
→ createGain() → connect chain → o.type = "square"
(buzzer-like tone) → g.gain.value = 0.1 (quiet) → o.start()
→ setTimeout(() => o.stop(), dur). Zero audio files, all
procedurally generated in the browser. Sound can be toggled off with the Sound button.
Pro Chronometer is ideal for: sports lap timing, running and cycling splits, workout interval tracking, productivity Pomodoro sessions, coding challenge timing, recipe and cooking timers, race management tools, educational demos of Web Audio API, and any application requiring a precision timer with data export and zero library dependencies. The CSV export makes it suitable for coaches tracking athlete split times across sessions.
Released under Creative Commons Attribution 4.0 International (CC BY 4.0). Free for personal and commercial use with attribution to Coodeverse.
<!DOCTYPE html>
<html lang="en">
<head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0"/> <title>Pro Chronometer</title> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;500;700&display=swap" rel="stylesheet"> <style> :root { --bg: #0d1117; --card: #161b22; --border: #30363d; --text: #f0f6fc; --accent: #58a6ff; --green: #3fb950; --red: #f85149; --purple: #a5a3ff; --gray: #8b949e; --fast: #79c879; --slow: #f85149; } [data-theme="light"] { --bg: #ffffff; --card: #f8f9fa; --border: #e1e4e8; --text: #24292f; --accent: #0969da; --gray: #57606a; } * { margin:0; padding:0; box-sizing:border-box; } body { min-height:100vh; background:var(--bg); color:var(--text); font-family:'Inter',sans-serif; display:flex; align-items:center; justify-content:center; transition:background 0.4s, color 0.4s; } .chronometer { background:var(--card); border-radius:28px; padding:40px 50px; max-width:520px; width:100%; border:1px solid var(--border); box-shadow:0 25px 50px rgba(0,0,0,0.5); } .header { display:flex; justify-content:space-between; align-items:center; margin-bottom:20px; } .title { font-size:1.15rem; font-weight:600; letter-spacing:2px; color:var(--gray); text-transform:uppercase; } .tools button { background:none; border:none; color:var(--gray); font-size:1.4rem; cursor:pointer; padding:8px; border-radius:8px; transition:0.2s; } .tools button:hover { background:rgba(255,255,255,0.1); color:var(--text); } .display { font-size:6.8rem; font-weight:300; letter-spacing:-6px; text-align:center; margin:10px 0 30px; line-height:1; background:linear-gradient(90deg,#fff,#c9d1d9); -webkit-background-clip:text; background-clip:text; color:transparent; } .stats { display:flex; justify-content:space-around; margin:20px 0; font-size:0.95rem; color:var(--gray); } .stats span strong { color:var(--accent); } .laps { background:rgba(0,0,0,0.25); border-radius:16px; padding:16px; max-height:300px; overflow-y:auto; border:1px solid var(--border); margin:20px 0; } .lap-item { display:grid; grid-template-columns:60px 1fr 1fr; padding:10px 0; border-bottom:1px solid var(--border); font-size:1.1rem; align-items:center; } .lap-item:last-child { border:none; } .lap-number { font-variant:small-caps; color:var(--gray); } .lap-time { font-weight:600; } .lap-split { font-size:0.9rem; color:var(--accent); } .fastest { background:rgba(63,185,80,0.15); border-left:4px solid var(--fast); } .slowest { background:rgba(248,81,73,0.15); border-left:4px solid var(--slow); } .controls { display:grid; grid-template-columns:1fr 1fr; gap:14px; margin:20px 0; } button { padding:16px; border-radius:50px; border:none; font-weight:600; font-size:1rem; cursor:pointer; text-transform:uppercase; letter-spacing:1px; transition:all 0.3s; } .btn-start { background:var(--green); color:white; grid-column:1/3; } .btn-stop { background:var(--red); color:white; } .btn-lap { background:transparent; color:var(--accent); border:2px solid var(--accent); } .btn-reset { background:transparent; color:var(--gray); border:2px solid var(--border); } button:hover:not(:disabled) { transform:translateY(-4px); box-shadow:0 12px 20px rgba(0,0,0,0.3); } button:disabled { opacity:0.35; cursor:not-allowed; } .footer { text-align:center; margin-top:20px; font-size:0.9rem; color:var(--gray); } .footer button { background:none; border:none; color:var(--accent); text-decoration:underline; cursor:pointer; } </style>
</head>
<body> <div class="chronometer" id="app"> <div class="header"> <div class="title">Pro Chronometer</div> <div class="tools"> <button id="soundToggle" title="Sound">Sound On</button> <button id="themeToggle" title="Theme">Light/Dark</button> <button id="fullscreenBtn" title="Fullscreen">Fullscreen</button> </div> </div> <div class="display" id="display">00:00.000</div> <div class="stats" id="stats"> <span>Laps: <strong>0</strong></span> <span>Best: <strong>--:--.---</strong></span> <span>Worst: <strong>--:--.---</strong></span> <span>Avg: <strong>--:--.---</strong></span> </div> <div class="laps" id="laps"></div> <div class="controls"> <button id="startBtn" class="btn-start">Start</button> <button id="stopBtn" class="btn-stop" disabled>Stop</button> <button id="lapBtn" class="btn-lap" disabled>Lap</button> <button id="resetBtn" class="btn-reset" disabled>Reset</button> </div> <div class="footer"> <button id="exportBtn" disabled>Export CSV</button> • Press <strong>Space</strong> to Start/Stop • <strong>L</strong> for Lap • <strong>R</strong> to Reset </div>
</div> <script> const el = id => document.getElementById(id); const display = el('display'), lapsEl = el('laps'), stats = el('stats'); const startBtn = el('startBtn'), stopBtn = el('stopBtn'), lapBtn = el('lapBtn'), resetBtn = el('resetBtn'); const soundToggle = el('soundToggle'), themeToggle = el('themeToggle'), fullscreenBtn = el('fullscreenBtn'), exportBtn = el('exportBtn'); let startTime = 0, elapsed = 0, interval = null, lapNum = 0, lastLapTime = 0; let laps = [], soundEnabled = true, isRunning = false; const beep = (freq=800, dur=100) => { if(!soundEnabled) return; const a=new AudioContext(); const o=a.createOscillator(); const g=a.createGain(); o.connect(g); g.connect(a.destination); o.frequency.value=freq; o.type="square"; g.gain.value=0.1; o.start(); setTimeout(()=>{o.stop()},dur); }; const vibrate = () => { if(navigator.vibrate) navigator.vibrate([100,50,100]); }; const format = ms => { const d = new Date(ms); const m = d.getUTCMinutes().toString().padStart(2,'0'); const s = d.getUTCSeconds().toString().padStart(2,'0'); const c = Math.floor(d.getUTCMilliseconds()/10).toString().padStart(2,'0'); return `${m}:${s}.${c}`; }; const updateDisplay = () => display.textContent = format(elapsed); const updateStats = () => { const times = laps.map(l => l.total); const best = Math.min(...times.filter(t=>t>0)); const worst = Math.max(...times.filter(t=>t>0)); const avg = times.length > 0 ? times.reduce((a,b)=>a+b,0)/times.length : 0; stats.innerHTML = ` <span>Laps: <strong>${lapNum}</strong></span> <span>Best: <strong>${isFinite(best)?format(best):"--:--.---"}</strong></span> <span>Worst: <strong>${isFinite(worst)?format(worst):"--:--.---"}</strong></span> <span>Avg: <strong>${avg>0?format(avg):"--:--.---"}</strong></span> `; }; const start = () => { beep(600,120); vibrate(); startTime = Date.now() - elapsed; interval = setInterval(()=>{ elapsed = Date.now()-startTime; updateDisplay(); },10); isRunning = true; startBtn.textContent = "Running"; startBtn.disabled=true; stopBtn.disabled=false; lapBtn.disabled=false; resetBtn.disabled=true; }; const stop = () => { beep(400,150); clearInterval(interval); isRunning = false; startBtn.textContent = "Resume"; startBtn.disabled=false; stopBtn.disabled=true; lapBtn.disabled=true; resetBtn.disabled=false; exportBtn.disabled=false; }; const reset = () => { clearInterval(interval); elapsed=0; lastLapTime=0; lapNum=0; laps=[]; updateDisplay(); lapsEl.innerHTML=""; updateStats(); exportBtn.disabled=true; startBtn.textContent="Start"; startBtn.disabled=false; stopBtn.disabled=true; lapBtn.disabled=true; resetBtn.disabled=true; }; const lap = () => { if(elapsed === 0) return; beep(1000,80); vibrate(); const total = elapsed; const split = total - lastLapTime; lastLapTime = total; laps.push({num:++lapNum, total, split}); const item = document.createElement('div'); item.className = 'lap-item'; item.innerHTML = ` <div class="lap-number">Lap ${lapNum}</div> <div class="lap-time">${format(total)}</div> <div class="lap-split">+${format(split)}</div> `; if(lapNum>1){ const times = laps.slice(0,-1).map(l=>l.split); const best = Math.min(...times), worst = Math.max(...times); if(split === best) item.classList.add('fastest'); if(split === worst) item.classList.add('slowest'); } lapsEl.prepend(item); updateStats(); }; startBtn.onclick = () => isRunning ? stop() : start(); stopBtn.onclick = stop; resetBtn.onclick = reset; lapBtn.onclick = lap; soundToggle.onclick = () => { soundEnabled=!soundEnabled; soundToggle.textContent = soundEnabled?"Sound On":"Sound Off"; }; themeToggle.onclick = () => document.body.dataset.theme = document.body.dataset.theme==="light"?"dark":"light"; fullscreenBtn.onclick = () => document.documentElement.requestFullscreen?.() || document.documentElement.webkitRequestFullscreen?.(); exportBtn.onclick = () => { let csv = "Lap,Total Time,Split\n"; laps.forEach(l=>csv += `${l.num},${format(l.total)},${format(l.split)}\n`); const blob = new Blob([csv],{type:'text/csv'}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href=url; a.download="laps.csv"; a.click(); }; document.addEventListener('keydown', e => { if(e.code==='Space'){ e.preventDefault(); isRunning?stop():start(); } if(e.key.toLowerCase()==='l') lap(); if(e.key.toLowerCase()==='r') reset(); }); updateStats();
</script>
</body>
</html>
Common questions about the free Pro Chronometer JavaScript stopwatch with laps and CSV export.
Yes — 100% free under Creative Commons Attribution 4.0. No sign-up, no payment. Click Download Project and use it in personal, commercial, sports, or productivity projects with attribution to Coodeverse.
Use Date.now() drift correction. On start: startTime = Date.now() - elapsed. Each interval tick: elapsed = Date.now() - startTime. The setInterval fires every 10ms. This recalculates from a fixed reference rather than accumulating a counter, preventing drift over long sessions. Time is formatted using new Date(ms).getUTCMinutes(), getUTCSeconds(), and Math.floor(getUTCMilliseconds()/10) with padStart(2,'0').
Each lap records total (current elapsed) and split = elapsed - lastLapTime. After recording: lastLapTime = total. Fastest/slowest uses Math.min(...times) and Math.max(...times) on the splits array. Fastest gets a green .fastest class (rgba(63,185,80,.15) background + green left border); slowest gets red .slowest class.
Build the string: let csv = "Lap,Total Time,Split\n"; loop laps: csv += l.num + "," + format(l.total) + "," + format(l.split) + "\n". Create file: const blob = new Blob([csv], {type: 'text/csv'}); const url = URL.createObjectURL(blob). Trigger download: create an anchor, set href = url, download = "laps.csv", call a.click(). No server, no library needed.
Create the chain: new AudioContext() → createOscillator() → createGain() → connect → o.type = "square" → g.gain.value = 0.1 → o.start() → setTimeout(() => o.stop(), dur). Use different frequencies: 600Hz for start, 400Hz for stop, 1000Hz for lap. Zero audio files — all generated procedurally in the browser.
Add: document.addEventListener('keydown', e => { if (e.code === 'Space') { e.preventDefault(); isRunning ? stop() : start(); } if (e.key.toLowerCase() === 'l') lap(); if (e.key.toLowerCase() === 'r') reset(); }). e.preventDefault() on Space prevents page scroll. e.key.toLowerCase() handles both uppercase and lowercase.
Browse the full collection at coodeverse.com/projects — Animated 3D Pie Chart, VORTEX Interactive Canvas, Word Typing Adventure, Word Builder, Holographic Login and more. All 100% free with complete HTML CSS JavaScript source code.