← Numbers

Heart Rate Monitor

A glowing ECG line sweeps across a dark screen, drawing a heartbeat every beat and wiping the old trace just ahead of it, like a hospital monitor. Beside it a heart and the beats-per-minute reading pulse with every spike, and the rate drifts a little from beat to beat.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-heart-rate-monitor="72" (the beats per minute) to an empty element and give it a width and height.

Options

Add any of these attributes to the same element.

data-varyHow far the rate wanders either side of its value. Default 4; 0 holds it steady.
data-sweepSeconds for the line to cross the screen. Default 3.2.
data-colorTrace and reading colour. Default #3ef08b.
data-labelText under the number. Default BPM.
data-griddata-grid="false" hides the faint grid.

The reading scales with font-size. Change the rate from a script by setting el._hrm.base = 120.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Heart Rate Monitor: a glowing ECG line sweeps across a dark screen,
    // drawing a heartbeat every beat and wiping the old trace just ahead of it,
    // like a hospital monitor. Beside it a heart and the beats-per-minute
    // reading pulse with every spike, and the reading drifts a little.
    // Usage: <div data-heart-rate-monitor="72"></div>   (beats per minute)
    // Optional: data-vary (how far the rate wanders either side, default 4;
    // 0 holds it steady), data-color (trace colour, default #3ef08b),
    // data-label (text under the number, default "BPM"), data-sweep (seconds
    // for the line to cross the screen, default 3.2), data-grid (show a faint
    // grid; data-grid="false" hides it).
    // Size it with width and height (default 100% × 120px); the reading scales
    // with font-size.
    var DEFAULTS = { bpm: 72, vary: 4, color: '#3ef08b', label: 'BPM', sweep: 3.2 };

    var css =
      ':where(.hrm){height:120px}' +
      '.hrm{position:relative;display:flex;align-items:stretch;gap:.8em;padding:.7em .9em;border-radius:14px;background:#06110c;color:#3ef08b;' +
      'box-shadow:inset 0 0 0 1px rgba(255,255,255,.06),inset 0 12px 30px rgba(0,0,0,.5);font-variant-numeric:tabular-nums;overflow:hidden}' +
      '.hrm-screen{position:relative;flex:1;min-width:0}' +
      '.hrm-screen canvas{position:absolute;inset:0;width:100%;height:100%;display:block}' +
      '.hrm-read{flex:none;display:flex;flex-direction:column;justify-content:center;align-items:flex-end;gap:.15em;min-width:3.6em}' +
      '.hrm-heart{width:1.1em;height:1.1em;fill:currentColor;filter:drop-shadow(0 0 .3em currentColor);transform-origin:50% 60%}' +
      '.hrm-num{font-size:2.3em;font-weight:800;letter-spacing:-.03em;line-height:1;text-shadow:0 0 .35em currentColor}' +
      '.hrm-label{font-size:.7em;font-weight:700;letter-spacing:.14em;opacity:.7}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function bump(t, at, width) { var x = (t - at) / width; return Math.exp(-x * x); }
    // One heartbeat as a function of seconds since it began: a small P wave,
    // the sharp QRS spike and a rounded T wave. 0 is the baseline, 1 the peak.
    function ecg(t) {
      return 0.1 * bump(t, 0.08, 0.035) - 0.1 * bump(t, 0.185, 0.012) + bump(t, 0.21, 0.014) -
        0.28 * bump(t, 0.24, 0.014) + 0.2 * bump(t, 0.44, 0.06);
    }
    var SPIKE = 0.21;

    function setup(el) {
      var d = el.dataset;
      var h = {
        el: el, base: parseFloat(el.getAttribute('data-heart-rate-monitor')) || DEFAULTS.bpm,
        vary: d.vary !== undefined ? +d.vary : DEFAULTS.vary, color: d.color || DEFAULTS.color,
        sweep: +d.sweep || DEFAULTS.sweep, grid: d.grid !== 'false', still: reduced(),
        t: 0, beatAt: 0, bpm: 0, ys: [], head: 0, headF: 0, raf: 0, last: 0, visible: false, spiked: false
      };
      h.bpm = h.base;
      el.classList.add('hrm');
      el.style.color = h.color;
      el.textContent = '';
      var screen = document.createElement('div');
      screen.className = 'hrm-screen';
      screen.setAttribute('aria-hidden', 'true');
      h.canvas = document.createElement('canvas');
      screen.appendChild(h.canvas);
      el.appendChild(screen);
      var read = document.createElement('div');
      read.className = 'hrm-read';
      read.innerHTML = '<svg class="hrm-heart" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21.4l-1.5-1.3C5.4 15.4 2 12.3 2 8.5 2 5.4 4.4 3 7.5 3c1.7 0 3.4.8 4.5 2.1C13.1 3.8 14.8 3 16.5 3 19.6 3 22 5.4 22 8.5c0 3.8-3.4 6.9-8.5 11.6L12 21.4z"/></svg>' +
        '<div class="hrm-num"></div><div class="hrm-label"></div>';
      el.appendChild(read);
      h.heart = read.querySelector('.hrm-heart');
      h.num = read.querySelector('.hrm-num');
      read.querySelector('.hrm-label').textContent = d.label !== undefined ? d.label : DEFAULTS.label;
      h.num.textContent = Math.round(h.bpm);
      el.setAttribute('role', 'img');
      el.setAttribute('aria-label', 'Heart rate ' + Math.round(h.base) + ' ' + (d.label || 'beats per minute'));
      h.ctx = h.canvas.getContext('2d');
      h.screen = screen;
      resize(h);
      new ResizeObserver(function () { resize(h); if (h.still || !h.raf) { if (h.still) prefill(h); draw(h); } }).observe(screen);
      if (h.still) { prefill(h); draw(h); }
      return h;
    }

    function resize(h) {
      var w = h.screen.clientWidth, ht = h.screen.clientHeight, dpr = Math.min(2, window.devicePixelRatio || 1);
      h.canvas.width = Math.round(w * dpr);
      h.canvas.height = Math.round(ht * dpr);
      h.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      if (w !== h.w) {
        var old = h.ys;
        h.ys = new Array(Math.max(1, Math.ceil(w))).fill(null);
        for (var i = 0; i < Math.min(old.length, h.ys.length); i++) h.ys[i] = old[i];
        h.headF = Math.min(h.headF, h.ys.length - 1);
        h.head = Math.floor(h.headF);
      }
      h.w = w; h.h = ht;
    }

    // Fill the whole screen with trace, for the still (reduced motion) view.
    function prefill(h) {
      var per = h.sweep / h.ys.length, gap = 60 / h.base;
      for (var i = 0; i < h.ys.length; i++) {
        var t = i * per;
        h.ys[i] = ecg((t + 0.3) % gap);
      }
      h.head = -1;
    }

    // Advance time by dt seconds, writing new columns of trace.
    function advance(h, dt) {
      var pxPerSec = h.ys.length / h.sweep, steps = dt * pxPerSec, n = h.ys.length;
      var cols = Math.floor(h.headF + steps) - Math.floor(h.headF);
      for (var k = 1; k <= cols; k++) {
        var t = h.t + dt * k / cols;
        var since = t - h.beatAt;
        if (since >= 60 / h.bpm) {
          h.beatAt += 60 / h.bpm;
          since = t - h.beatAt;
          h.spiked = false;
          // The rate wanders a little from beat to beat, drifting back to the base.
          if (h.vary) h.bpm = h.base + (h.bpm - h.base) * 0.6 + (Math.random() * 2 - 1) * h.vary * 0.8;
        }
        if (!h.spiked && since >= SPIKE) { h.spiked = true; beat(h); }
        h.ys[(Math.floor(h.headF) + k) % n] = ecg(since) + (Math.random() - 0.5) * 0.006;
      }
      h.headF = (h.headF + steps) % n;
      h.head = Math.floor(h.headF);
      h.t += dt;
    }

    function beat(h) {
      h.num.textContent = Math.round(h.bpm);
      anime.animate(h.heart, { scale: [1, 1.28, 1], duration: 380, ease: 'out(2)' });
      anime.animate(h.num, { scale: [1.06, 1], duration: 300, ease: 'out(2)' });
    }

    function draw(h) {
      var ctx = h.ctx, w = h.w, ht = h.h, n = h.ys.length;
      ctx.clearRect(0, 0, w, ht);
      if (h.grid) {
        ctx.strokeStyle = h.color;
        ctx.globalAlpha = 0.08;
        ctx.lineWidth = 1;
        var g = ht / 4;
        ctx.beginPath();
        for (var x = g / 2; x < w; x += g) { ctx.moveTo(Math.round(x) + 0.5, 0); ctx.lineTo(Math.round(x) + 0.5, ht); }
        for (var y = g / 2; y < ht; y += g) { ctx.moveTo(0, Math.round(y) + 0.5); ctx.lineTo(w, Math.round(y) + 0.5); }
        ctx.stroke();
        ctx.globalAlpha = 1;
      }
      var mid = ht * 0.62, amp = ht * 0.5, gap = Math.max(14, w * 0.05);
      function Y(v) { return mid - v * amp; }
      ctx.lineJoin = 'round';
      ctx.lineCap = 'round';
      ctx.strokeStyle = h.color;
      // Draw the trace in two passes: a wide soft glow, then the bright line.
      [[5, 0.18], [2, 1]].forEach(function (pass) {
        ctx.lineWidth = pass[0];
        ctx.globalAlpha = pass[1];
        ctx.beginPath();
        var pen = false;
        for (var i = 0; i < n; i++) {
          var ahead = (i - h.head + n) % n;
          var v = h.ys[i];
          if (v === null || (h.head >= 0 && ahead > 0 && ahead < gap)) { pen = false; continue; }
          if (!pen) { ctx.moveTo(i, Y(v)); pen = true; } else ctx.lineTo(i, Y(v));
        }
        ctx.stroke();
      });
      ctx.globalAlpha = 1;
      // The bright dot at the writing head.
      if (h.head >= 0 && h.ys[h.head] !== null) {
        var hy = Y(h.ys[h.head]);
        ctx.fillStyle = h.color;
        ctx.globalAlpha = 0.25;
        ctx.beginPath(); ctx.arc(h.head, hy, 7, 0, 6.2832); ctx.fill();
        ctx.globalAlpha = 1;
        ctx.fillStyle = '#fff';
        ctx.beginPath(); ctx.arc(h.head, hy, 2.4, 0, 6.2832); ctx.fill();
      }
    }

    function frame(h, now) {
      var dt = Math.min(0.05, (now - (h.last || now)) / 1000);
      h.last = now;
      advance(h, dt);
      draw(h);
      h.raf = requestAnimationFrame(function (t) { frame(h, t); });
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var h = e.target._hrm;
          h.visible = e.isIntersecting;
          cancelAnimationFrame(h.raf);
          h.raf = 0;
          if (h.visible) { h.last = 0; h.raf = requestAnimationFrame(function (t) { frame(h, t); }); }
        });
      }, { threshold: 0.1 });
      document.querySelectorAll('[data-heart-rate-monitor]').forEach(function (el) {
        if (el._hrm) return;
        var h = (el._hrm = setup(el));
        if (!h.still) io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-heart-rate-monitor="72" style="height: 140px"></div>

<!-- a racing heart, in red: -->
<div data-heart-rate-monitor="128" data-color="#ff4d6d" data-vary="8"></div>