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
- Paste the snippet just before
</head>. - 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-vary | How far the rate wanders either side of its value. Default 4; 0 holds it steady. |
data-sweep | Seconds for the line to cross the screen. Default 3.2. |
data-color | Trace and reading colour. Default #3ef08b. |
data-label | Text under the number. Default BPM. |
data-grid | data-grid="false" hides the faint grid. |
The reading scales with font-size. Change the rate from a script by setting el._hrm.base = 120.
<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><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>