Open demo

CSS code

HTML
<div><span class="lbl">Stopwatch with laps</span>
<div class="sw" data-stopwatch>
  <div class="t" data-show>00:00.0</div>
  <div class="btns"><button type="button" data-action="toggle">Start</button><button type="button" class="ghost" data-action="lap">Lap</button><button type="button" class="ghost" data-action="reset">Reset</button></div>
  <ol class="laps" data-laps aria-label="Laps"></ol>
  <p class="sr-only" data-say aria-live="polite"></p>
</div></div>
CSS
/* Stopwatch: the script measures from Date.now() timestamps (not by counting ticks), redraws the
   display while running, and announces start, stop, laps and reset through a polite [data-say] region. */
.sw{background:#fff;border-radius:16px;padding:26px 30px;box-shadow:0 2px 14px rgba(15,23,42,.1);display:inline-block;min-width:300px}
.sw .t{font-size:3rem;font-weight:300;font-variant-numeric:tabular-nums;letter-spacing:-.01em}
.sw .btns{display:flex;gap:9px;margin-top:18px}
.sw button{font:inherit;font-size:.85rem;font-weight:600;padding:9px 18px;border-radius:9px;border:0;
  cursor:pointer;background:#0f172a;color:#fff;transition:opacity .15s}
.sw button.ghost{background:#e2e8f0;color:#334155}
.sw button:hover{opacity:.86}
.sw button:focus-visible,.sw .laps:focus-visible{outline:2px solid #2563eb;outline-offset:2px}
.sw .laps{list-style:none;margin-top:16px;font-size:.8rem;color:#64748b;font-variant-numeric:tabular-nums;
  display:flex;flex-direction:column;gap:4px;max-height:96px;overflow-y:auto}
.sw .laps:empty{display:none}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
@media (prefers-reduced-motion:reduce){.sw button{transition:none}}
JavaScript
// Stopwatch. Elapsed time is always Date.now() minus a start timestamp, so it stays right when the tab is
// in the background and timers are throttled; requestAnimationFrame only redraws the digits.
document.querySelectorAll('[data-stopwatch]').forEach(function (root) {
  var show = root.querySelector('[data-show]'), laps = root.querySelector('[data-laps]'), say = root.querySelector('[data-say]');
  var toggle = root.querySelector('[data-action="toggle"]');
  var started = 0, banked = 0, running = false, frame = 0;
  function elapsed() { return banked + (running ? Date.now() - started : 0); }
  function p2(n) { return String(n).padStart(2, '0'); }
  function fmt(ms) { return p2(Math.floor(ms / 6e4)) + ':' + p2(Math.floor(ms % 6e4 / 1000)) + '.' + Math.floor(ms % 1000 / 100); }
  function spoken(ms) {
    var m = Math.floor(ms / 6e4), s = (Math.floor(ms % 6e4 / 100) / 10).toFixed(1);
    return (m ? m + (m === 1 ? ' minute ' : ' minutes ') : '') + s + ' seconds';
  }
  function draw() { show.textContent = fmt(elapsed()); if (running) frame = requestAnimationFrame(draw); }
  toggle.addEventListener('click', function () {
    if (running) {
      banked = elapsed(); running = false; cancelAnimationFrame(frame);
      toggle.textContent = 'Start'; draw(); say.textContent = 'Stopped at ' + spoken(banked);
    } else {
      started = Date.now(); running = true;
      toggle.textContent = 'Stop'; say.textContent = 'Started'; draw();
    }
  });
  root.querySelector('[data-action="lap"]').addEventListener('click', function () {
    var t = elapsed();
    if (!t) return;
    var li = document.createElement('li');
    li.textContent = 'Lap ' + (laps.children.length + 1) + ' — ' + fmt(t);
    laps.prepend(li);
    laps.tabIndex = 0; // the list scrolls once it is long, so keyboard users can reach it
    say.textContent = 'Lap ' + laps.children.length + ', ' + spoken(t);
  });
  root.querySelector('[data-action="reset"]').addEventListener('click', function () {
    running = false; banked = 0; cancelAnimationFrame(frame);
    toggle.textContent = 'Start'; laps.innerHTML = ''; laps.removeAttribute('tabindex');
    draw(); say.textContent = 'Reset';
  });
});

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div><span class="lbl">Stopwatch with laps</span>
<div class="sw card d-inline-block" data-stopwatch><div class="card-body">
  <div class="t fw-light" data-show>00:00.0</div>
  <div class="btns d-flex"><button type="button" class="btn btn-dark" data-action="toggle">Start</button><button type="button" class="btn btn-light" data-action="lap">Lap</button><button type="button" class="btn btn-light" data-action="reset">Reset</button></div>
  <ol class="laps list-unstyled d-flex flex-column gap-1 mt-3 mb-0" data-laps aria-label="Laps"></ol>
  <p class="visually-hidden" data-say aria-live="polite"></p>
</div></div></div>
CSS
/* Stopwatch on a Bootstrap .card with .btn buttons, recoloured through --bs-card-* and --bs-btn-* variables.
   The script measures from Date.now() timestamps and announces changes through a polite [data-say] region. */
.sw{--bs-card-border-width:0;--bs-card-border-radius:16px;--bs-card-spacer-y:26px;--bs-card-spacer-x:30px;
  --bs-card-color:#1e293b;min-width:300px;box-shadow:0 2px 14px rgba(15,23,42,.1)}
.sw .t{font-size:3rem;font-variant-numeric:tabular-nums;letter-spacing:-.01em;line-height:normal}
.sw .btn{--bs-btn-font-size:.85rem;--bs-btn-font-weight:600;--bs-btn-padding-x:18px;--bs-btn-padding-y:9px;
  --bs-btn-border-radius:9px;--bs-btn-border-width:0;--bs-btn-line-height:normal;
  --bs-btn-bg:#0f172a;--bs-btn-color:#fff;--bs-btn-hover-bg:#0f172a;--bs-btn-hover-color:#fff;
  --bs-btn-active-bg:#0f172a;--bs-btn-active-color:#fff;transition:opacity .15s}
.sw .btn-light{--bs-btn-bg:#e2e8f0;--bs-btn-color:#334155;--bs-btn-hover-bg:#e2e8f0;--bs-btn-hover-color:#334155;
  --bs-btn-active-bg:#e2e8f0;--bs-btn-active-color:#334155}
.sw .btns{gap:9px;margin-top:18px}
.sw .btn:hover{opacity:.86}
.sw .btn:focus-visible,.sw .laps:focus-visible{outline:2px solid #2563eb;outline-offset:2px;box-shadow:none}
.sw .laps{font-size:.8rem;color:#64748b;font-variant-numeric:tabular-nums;max-height:96px;overflow-y:auto}
.sw .laps:empty{display:none!important}
@media (prefers-reduced-motion:reduce){.sw .btn{transition:none}}
JavaScript
// Stopwatch. Elapsed time is always Date.now() minus a start timestamp, so it stays right when the tab is
// in the background and timers are throttled; requestAnimationFrame only redraws the digits.
document.querySelectorAll('[data-stopwatch]').forEach(function (root) {
  var show = root.querySelector('[data-show]'), laps = root.querySelector('[data-laps]'), say = root.querySelector('[data-say]');
  var toggle = root.querySelector('[data-action="toggle"]');
  var started = 0, banked = 0, running = false, frame = 0;
  function elapsed() { return banked + (running ? Date.now() - started : 0); }
  function p2(n) { return String(n).padStart(2, '0'); }
  function fmt(ms) { return p2(Math.floor(ms / 6e4)) + ':' + p2(Math.floor(ms % 6e4 / 1000)) + '.' + Math.floor(ms % 1000 / 100); }
  function spoken(ms) {
    var m = Math.floor(ms / 6e4), s = (Math.floor(ms % 6e4 / 100) / 10).toFixed(1);
    return (m ? m + (m === 1 ? ' minute ' : ' minutes ') : '') + s + ' seconds';
  }
  function draw() { show.textContent = fmt(elapsed()); if (running) frame = requestAnimationFrame(draw); }
  toggle.addEventListener('click', function () {
    if (running) {
      banked = elapsed(); running = false; cancelAnimationFrame(frame);
      toggle.textContent = 'Start'; draw(); say.textContent = 'Stopped at ' + spoken(banked);
    } else {
      started = Date.now(); running = true;
      toggle.textContent = 'Stop'; say.textContent = 'Started'; draw();
    }
  });
  root.querySelector('[data-action="lap"]').addEventListener('click', function () {
    var t = elapsed();
    if (!t) return;
    var li = document.createElement('li');
    li.textContent = 'Lap ' + (laps.children.length + 1) + ' — ' + fmt(t);
    laps.prepend(li);
    laps.tabIndex = 0; // the list scrolls once it is long, so keyboard users can reach it
    say.textContent = 'Lap ' + laps.children.length + ', ' + spoken(t);
  });
  root.querySelector('[data-action="reset"]').addEventListener('click', function () {
    running = false; banked = 0; cancelAnimationFrame(frame);
    toggle.textContent = 'Start'; laps.innerHTML = ''; laps.removeAttribute('tabindex');
    draw(); say.textContent = 'Reset';
  });
});

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-[#64748b] uppercase">Stopwatch with laps</span>
<div class="inline-block min-w-[300px] rounded-2xl bg-white px-[30px] py-[26px] shadow-[0_2px_14px_rgba(15,23,42,.1)]" data-stopwatch>
  <div class="text-5xl leading-[normal] font-light tracking-[-.01em] tabular-nums" data-show>00:00.0</div>
  <div class="mt-[18px] flex gap-[9px]"><button type="button" class="cursor-pointer rounded-[9px] bg-[#0f172a] px-[18px] py-[9px] text-[.85rem] font-semibold text-white transition-opacity duration-150 hover:opacity-86 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#2563eb] motion-reduce:transition-none" data-action="toggle">Start</button><button type="button" class="cursor-pointer rounded-[9px] bg-[#e2e8f0] px-[18px] py-[9px] text-[.85rem] font-semibold text-[#334155] transition-opacity duration-150 hover:opacity-86 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#2563eb] motion-reduce:transition-none" data-action="lap">Lap</button><button type="button" class="cursor-pointer rounded-[9px] bg-[#e2e8f0] px-[18px] py-[9px] text-[.85rem] font-semibold text-[#334155] transition-opacity duration-150 hover:opacity-86 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#2563eb] motion-reduce:transition-none" data-action="reset">Reset</button></div>
  <ol class="mt-4 flex max-h-24 flex-col gap-1 overflow-y-auto text-[.8rem] text-[#64748b] tabular-nums empty:hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#2563eb]" data-laps aria-label="Laps"></ol>
  <p class="sr-only" data-say aria-live="polite"></p>
</div></div>
JavaScript
// Stopwatch. Elapsed time is always Date.now() minus a start timestamp, so it stays right when the tab is
// in the background and timers are throttled; requestAnimationFrame only redraws the digits.
document.querySelectorAll('[data-stopwatch]').forEach(function (root) {
  var show = root.querySelector('[data-show]'), laps = root.querySelector('[data-laps]'), say = root.querySelector('[data-say]');
  var toggle = root.querySelector('[data-action="toggle"]');
  var started = 0, banked = 0, running = false, frame = 0;
  function elapsed() { return banked + (running ? Date.now() - started : 0); }
  function p2(n) { return String(n).padStart(2, '0'); }
  function fmt(ms) { return p2(Math.floor(ms / 6e4)) + ':' + p2(Math.floor(ms % 6e4 / 1000)) + '.' + Math.floor(ms % 1000 / 100); }
  function spoken(ms) {
    var m = Math.floor(ms / 6e4), s = (Math.floor(ms % 6e4 / 100) / 10).toFixed(1);
    return (m ? m + (m === 1 ? ' minute ' : ' minutes ') : '') + s + ' seconds';
  }
  function draw() { show.textContent = fmt(elapsed()); if (running) frame = requestAnimationFrame(draw); }
  toggle.addEventListener('click', function () {
    if (running) {
      banked = elapsed(); running = false; cancelAnimationFrame(frame);
      toggle.textContent = 'Start'; draw(); say.textContent = 'Stopped at ' + spoken(banked);
    } else {
      started = Date.now(); running = true;
      toggle.textContent = 'Stop'; say.textContent = 'Started'; draw();
    }
  });
  root.querySelector('[data-action="lap"]').addEventListener('click', function () {
    var t = elapsed();
    if (!t) return;
    var li = document.createElement('li');
    li.textContent = 'Lap ' + (laps.children.length + 1) + ' — ' + fmt(t);
    laps.prepend(li);
    laps.tabIndex = 0; // the list scrolls once it is long, so keyboard users can reach it
    say.textContent = 'Lap ' + laps.children.length + ', ' + spoken(t);
  });
  root.querySelector('[data-action="reset"]').addEventListener('click', function () {
    running = false; banked = 0; cancelAnimationFrame(frame);
    toggle.textContent = 'Start'; laps.innerHTML = ''; laps.removeAttribute('tabindex');
    draw(); say.textContent = 'Reset';
  });
});

About this set

A stopwatch card with a large tenths-of-a-second readout, a dark Start button that becomes Stop while running, Lap and Reset buttons in a lighter style, and a scrolling list of split times with the newest lap on top. Use it for workout and cooking apps, timed exercises, usability tests or any interface where someone measures how long something takes. Elapsed time is always Date.now() minus the moment Start was pressed, plus any time banked before a pause, so it stays accurate when the tab is in the background and the browser throttles timers; requestAnimationFrame only redraws the digits while the stopwatch runs and stops when it is paused. The script works on every [data-stopwatch] element, so several can share a page. The buttons are real button elements, and the laps are an ordered list that becomes keyboard focusable once it holds entries, so it can be scrolled without a mouse. The Bootstrap version uses .card and .btn restyled through their CSS variables, and the Tailwind version uses utility classes only.

What’s included

  • Start and stop, lap and reset on real buttons
  • Elapsed time from Date.now() timestamps, accurate in background tabs
  • Display redrawn with requestAnimationFrame only while running
  • Scrolling lap list, newest first
  • Start, stop, lap and reset announced through a polite live region

Accessibility

The controls are native buttons with visible text and a 2px blue focus outline. A visually hidden polite live region announces Started, Stopped at 1 minute 23.4 seconds, each lap and Reset, while the running digits are not announced. The lap list has an accessible name and becomes focusable once it has entries, and the button hover fade is removed under prefers-reduced-motion.

Customise it

Change the card and button colours on .sw, .sw button and .sw button.ghost in plain CSS. In Bootstrap edit the --bs-btn-* variables on .sw .btn and .sw .btn-light and the --bs-card-* variables on .sw; in Tailwind the bg-, text- and outline- utilities on each button.