← Numbers

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

  1. Paste the snippet just before </head>.
  2. Add data-stopwatch to 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-autoplayIt presses its own buttons while in view (start, a few laps, stop, reset) until someone presses one.
data-lapsHow many laps the list shows. Default 4.
data-colorColour 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.

1 · Snippet
<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>
2 · Your HTML
<div data-stopwatch></div>

<!-- in teal, showing the last six laps: -->
<div data-stopwatch data-color="#0d9488" data-laps="6"></div>