Stopwatch
A working stopwatch that counts up to the hundredth of a second. A ring around the readout sweeps once a minute, and each lap slides into a list with the fastest in green and the slowest in red. Press Start, take a few laps, then Stop and Reset.
How to use it
- Paste the snippet just before
</head>. - Add
data-stopwatchto an empty element. The script builds the dial, the buttons and the lap list.
Options
Add any of these attributes to the same element.
data-autoplay | It presses its own buttons while in view (start, a few laps, stop, reset) until someone presses one. |
data-laps | How many laps the list shows. Default 4. |
data-color | Colour of the ring and the Start button. Default #6a0dad. |
Each lap fires a stopwatch:lap event on the element, with the lap and total times in milliseconds in event.detail.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Stopwatch: a stopwatch that counts up to the hundredth of a second, with
// Start/Stop, Lap and Reset. A ring around the readout sweeps once a
// minute, and each lap slides into a list with the fastest in green and the
// slowest in red.
// Usage: <div data-stopwatch></div>
// Optional: data-autoplay (it presses its own buttons while in view: start,
// a few laps, stop, reset, until someone presses one), data-laps (how many
// laps the list shows, default 4), data-color (ring and Start button,
// default #6a0dad).
// Script use: el.addEventListener('stopwatch:lap', e => e.detail.lap / .total)
var DEFAULTS = { laps: 4, color: '#6a0dad' };
var NS = 'http://www.w3.org/2000/svg';
var C = 2 * Math.PI * 88;
var css =
':where(.stw){--stw-size:200px;gap:1.2em 2em}' +
'.stw{display:flex;flex-wrap:wrap;align-items:center;justify-content:center;' +
'font-variant-numeric:tabular-nums;user-select:none;-webkit-user-select:none}' +
'.stw-dial{position:relative;width:var(--stw-size);height:var(--stw-size);flex:none}' +
'.stw-dial>svg{position:absolute;inset:0;width:100%;height:100%;transform:rotate(-90deg)}' +
'.stw-face{fill:#fff}' +
'.stw-ticks{stroke:currentColor;stroke-opacity:.18;stroke-width:1.4}' +
'.stw-arc{fill:none;stroke:var(--stw-color);stroke-width:6;stroke-linecap:round}' +
'.stw-dot{fill:var(--stw-color)}' +
'.stw-read{position:absolute;inset:0;display:grid;place-content:center;justify-items:center;gap:0.3em;text-align:center}' +
'.stw-time{font-size:calc(var(--stw-size)*.155);font-weight:700;letter-spacing:-0.02em;line-height:1}' +
'.stw-time small{font-size:.72em;opacity:.55;font-weight:600}' +
'.stw-sub{font-size:max(10px,calc(var(--stw-size)*.062));font-weight:600;letter-spacing:.08em;text-transform:uppercase;opacity:.5;min-height:1.2em}' +
'.stw-side{display:flex;flex-direction:column;gap:0.8em;width:12.5em;max-width:100%}' +
'.stw-btns{display:flex;gap:0.45em}' +
'.stw-btn{flex:1;font:inherit;font-size:0.88em;font-weight:700;padding:0.6em 0.4em;border-radius:999px;border:1px solid rgba(15,15,30,.12);' +
'background:#fff;color:inherit;cursor:pointer;text-align:center;transition:opacity .2s,transform .12s,background-color .2s}' +
'.stw-btn[aria-disabled="true"]{opacity:.4;cursor:default}' +
'.stw-btn.stw-go{background:var(--stw-color);border-color:transparent;color:#fff}' +
'.stw-btn.stw-go.stw-on{background:#e5484d}' +
'.stw-btn.stw-press{transform:scale(.92)}' +
'.stw-laps{list-style:none;margin:0;padding:0;font-size:0.88em;display:flex;flex-direction:column}' +
'.stw-laps li{display:flex;justify-content:space-between;gap:1em;padding:0.32em 0.1em;border-top:1px solid rgba(15,15,30,.08);overflow:hidden;box-sizing:border-box}' +
'.stw-laps li span:first-child{opacity:.6}' +
'.stw-laps li.stw-best span:last-child{color:#16a34a;font-weight:700}' +
'.stw-laps li.stw-worst span:last-child{color:#dc2626;font-weight:700}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function make(tag, cls, parent, text) {
var n = document.createElement(tag);
if (cls) n.className = cls;
if (text !== undefined) n.textContent = text;
parent.appendChild(n);
return n;
}
function svg(tag, attrs, parent) {
var n = document.createElementNS(NS, tag);
for (var k in attrs) n.setAttribute(k, attrs[k]);
parent.appendChild(n);
return n;
}
function pad(n) { return (n < 10 ? '0' : '') + n; }
// 83456 ms -> "01:23" and ".45"
function fmt(ms) {
var cs = Math.floor(ms / 10) % 100;
var s = Math.floor(ms / 1000);
var h = Math.floor(s / 3600);
var main = (h ? h + ':' : '') + pad(Math.floor(s / 60) % 60) + ':' + pad(s % 60);
return [main, '.' + pad(cs)];
}
function setup(el) {
var d = el.dataset;
var fake = !!el.closest('[aria-hidden="true"]'); // inside a card thumb: no real buttons
el.classList.add('stw');
el.style.setProperty('--stw-color', d.color || DEFAULTS.color);
el.textContent = '';
var w = {
el: el, running: false, start: 0, acc: 0, lapStart: 0, laps: [], raf: 0, visible: false,
max: parseInt(d.laps, 10) || DEFAULTS.laps
};
var dial = make('div', 'stw-dial', el);
var s = svg('svg', { viewBox: '0 0 200 200', 'aria-hidden': 'true' }, dial);
svg('circle', { cx: 100, cy: 100, r: 98, 'class': 'stw-face' }, s);
var ticks = '';
for (var i = 0; i < 60; i++) {
var a = i * 6 * Math.PI / 180, r1 = i % 5 ? 76 : 71;
ticks += 'M' + (100 + Math.cos(a) * r1).toFixed(2) + ' ' + (100 + Math.sin(a) * r1).toFixed(2) +
'L' + (100 + Math.cos(a) * 80).toFixed(2) + ' ' + (100 + Math.sin(a) * 80).toFixed(2);
}
svg('path', { d: ticks, 'class': 'stw-ticks' }, s);
svg('circle', { cx: 100, cy: 100, r: 88, fill: 'none', stroke: 'currentColor', 'stroke-opacity': '.07', 'stroke-width': 6 }, s);
w.arc = svg('circle', { cx: 100, cy: 100, r: 88, 'class': 'stw-arc', 'stroke-dasharray': C, 'stroke-dashoffset': C }, s);
w.dot = svg('circle', { cx: 188, cy: 100, r: 5.5, 'class': 'stw-dot' }, s);
var read = make('div', 'stw-read', dial);
w.time = make('div', 'stw-time', read);
w.time.setAttribute('role', 'timer');
w.sub = make('div', 'stw-sub', read, '');
var side = make('div', 'stw-side', el);
var btns = make('div', 'stw-btns', side);
function button(label, cls, fn) {
var b = make(fake ? 'span' : 'button', 'stw-btn ' + cls, btns, label);
if (!fake) {
b.type = 'button';
b.addEventListener('click', function (e) {
if (e.isTrusted) w.user = true; // a real press ends the autoplay
if (b.getAttribute('aria-disabled') !== 'true') fn();
});
}
return b;
}
w.go = button('Start', 'stw-go', function () { toggle(w); });
w.lapBtn = button('Lap', 'stw-lap', function () { lap(w); });
w.resetBtn = button('Reset', 'stw-reset', function () { reset(w); });
w.list = make('ol', 'stw-laps', side);
w.list.setAttribute('aria-label', 'Laps');
refresh(w);
render(w);
return w;
}
function elapsed(w) { return w.acc + (w.running ? performance.now() - w.start : 0); }
function refresh(w) {
var t = elapsed(w);
w.go.textContent = w.running ? 'Stop' : (t ? 'Resume' : 'Start');
w.go.classList.toggle('stw-on', w.running);
w.lapBtn.setAttribute('aria-disabled', w.running ? 'false' : 'true');
w.resetBtn.setAttribute('aria-disabled', !w.running && t ? 'false' : 'true');
}
function render(w) {
var t = elapsed(w);
var f = fmt(t);
w.time.innerHTML = f[0] + '<small>' + f[1] + '</small>';
var p = (t % 60000) / 60000;
w.arc.setAttribute('stroke-dashoffset', (C * (1 - p)).toFixed(2));
var a = p * Math.PI * 2;
w.dot.setAttribute('cx', (100 + Math.cos(a) * 88).toFixed(2));
w.dot.setAttribute('cy', (100 + Math.sin(a) * 88).toFixed(2));
w.sub.textContent = w.laps.length ? 'Lap ' + (w.laps.length + 1) + ' · ' + fmt(t - w.lapStart).join('') : (t ? '' : 'Ready');
}
function loop(w) {
if (w.raf) return;
(function frame() {
render(w);
w.raf = w.running && w.visible ? requestAnimationFrame(frame) : 0;
})();
}
function toggle(w) {
if (w.running) { w.acc = elapsed(w); w.running = false; }
else { w.start = performance.now(); w.running = true; }
refresh(w);
loop(w);
}
function lap(w) {
var t = elapsed(w);
var split = t - w.lapStart;
w.lapStart = t;
w.laps.push(split);
var li = document.createElement('li');
make('span', '', li, 'Lap ' + w.laps.length);
make('span', '', li, fmt(split).join(''));
w.list.insertBefore(li, w.list.firstChild);
while (w.list.children.length > w.max) w.list.removeChild(w.list.lastChild);
marks(w);
if (!reduced()) {
var h = li.offsetHeight;
anime.animate(li, { height: [0, h], opacity: [0, 1], x: [-14, 0], duration: 420, ease: 'out(3)',
onComplete: function () { li.style.height = ''; } });
}
w.el.dispatchEvent(new CustomEvent('stopwatch:lap', { detail: { lap: split, total: t } }));
}
// Fastest lap in green, slowest in red, once there are two to compare.
function marks(w) {
var n = w.laps.length;
var best = Math.min.apply(null, w.laps), worst = Math.max.apply(null, w.laps);
Array.prototype.forEach.call(w.list.children, function (li, i) {
var v = w.laps[n - 1 - i];
li.classList.toggle('stw-best', n > 1 && v === best);
li.classList.toggle('stw-worst', n > 1 && v === worst);
});
}
function reset(w) {
w.running = false;
w.acc = 0;
w.lapStart = 0;
w.laps = [];
var rows = Array.prototype.slice.call(w.list.children);
if (rows.length && !reduced()) {
anime.animate(rows, { opacity: 0, x: 14, duration: 260, delay: anime.stagger(40), ease: 'in(2)',
onComplete: function () { rows.forEach(function (r) { if (r.parentNode) r.parentNode.removeChild(r); }); } });
} else w.list.textContent = '';
refresh(w);
render(w);
}
// The pretend user: start, three laps, stop, reset, and round again.
function autoplay(w) {
var steps = [[900, 'go'], [1700, 'lapBtn'], [1300, 'lapBtn'], [1900, 'lapBtn'], [1200, 'go'], [1800, 'resetBtn']];
var i = 0;
(function next() {
setTimeout(function () {
if (w.user) return;
if (!w.visible) { next(); return; }
var b = w[steps[i][1]];
b.classList.add('stw-press');
setTimeout(function () { b.classList.remove('stw-press'); }, 160);
if (steps[i][1] === 'go') toggle(w);
else if (steps[i][1] === 'lapBtn') lap(w);
else reset(w);
i = (i + 1) % steps.length;
next();
}, steps[i][0]);
})();
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var w = e.target._stw;
w.visible = e.isIntersecting;
if (w.visible) {
loop(w);
if (w.auto && !w.autoOn) { w.autoOn = true; autoplay(w); }
}
});
}, { threshold: 0.2 });
document.querySelectorAll('[data-stopwatch]').forEach(function (el) {
if (el._stw) return;
var w = el._stw = setup(el);
w.auto = el.dataset.autoplay !== undefined && el.dataset.autoplay !== 'false';
el.addEventListener('pointerdown', function (e) { if (e.isTrusted && e.target.closest('.stw-btn')) w.user = true; });
io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-stopwatch></div>
<!-- in teal, showing the last six laps: -->
<div data-stopwatch data-color="#0d9488" data-laps="6"></div>