. Stopwatch JavaScript Laps CSV Export – Free | Coodeverse Skip to main content

Pro Chronometer – Free Precision Stopwatch JavaScript with Laps, Split Times & CSV Export

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.

What's Included in the Download

How the Drift-Corrected Timer Works

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.

How Web Audio API Beeps Work

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.

Best Use Cases

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.

License

Released under Creative Commons Attribution 4.0 International (CC BY 4.0). Free for personal and commercial use with attribution to Coodeverse.

Source Code

<!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>

Frequently Asked Questions

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.