Real-time Line Chart
A live line chart. It scrolls smoothly along as new readings stream in at the right, a glowing dot rides the newest point, and the current reading sits in the corner beside a blinking Live badge.
How to use it
- Paste the snippet just before
</head>. - Add
data-real-time-line-chartto an element with a height. On its own it plots made-up readings. - For real data add
data-source="manual"and callanimacious.realTimeLineChart.push(el, value)for each reading.
Options
Add any of these attributes to the same element.
data-label | Name shown with the reading, e.g. CPU. |
data-min / data-max | Fix the scale. Default: it eases to fit the data. |
data-prefix / data-suffix | Text around the reading, e.g. %. |
data-decimals | Decimal places on the reading. Default 0. |
data-color | Line colour. Default #a855f7. |
data-interval | Time between made-up readings, and how long each new one takes to slide in. Default 500 ms. |
data-window | Readings across the chart. Default 40. |
data-source | manual turns off the made-up readings. |
It’s drawn on a canvas and stops while it’s off screen. With reduced motion the chart stays still.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Real-time Line Chart: a line chart that scrolls smoothly along as new
// readings stream in at the right. A glowing dot rides the newest point,
// the current reading sits in the corner beside a blinking Live badge, and
// the scale eases to fit (unless you fix it with data-min / data-max).
// Usage: <div data-real-time-line-chart style="height:240px"></div>
// Optional: data-label (name shown with the reading, e.g. "CPU"),
// data-min / data-max (fixed scale; default fits the data), data-suffix /
// data-prefix (around the reading, e.g. "%"), data-decimals (default 0),
// data-color (default #a855f7), data-interval (ms between readings,
// default 500), data-window (readings across the chart, default 40),
// data-source="manual" (no made-up readings; feed it yourself).
// Script use: animacious.realTimeLineChart.push(el, 42) adds a reading.
var DEFAULTS = { color: '#a855f7', interval: 500, window: 40, decimals: 0 };
var css =
'.rtl{position:relative;font-variant-numeric:tabular-nums}' +
'.rtl canvas{position:absolute;inset:0;width:100%;height:100%;display:block}' +
'.rtl-head{position:absolute;left:0;top:0;display:flex;align-items:baseline;gap:8px;pointer-events:none;line-height:1}' +
'.rtl-live{display:inline-flex;align-items:center;gap:5px;padding:3px 7px;border-radius:99px;background:rgba(244,63,94,.12);color:#f43f5e;font-size:10px;font-weight:800;letter-spacing:.06em;text-transform:uppercase;align-self:center}' +
'.rtl-live i{width:6px;height:6px;border-radius:50%;background:currentColor;animation:rtl-blink 1.2s ease-in-out infinite}' +
'.rtl-label{font-size:12px;font-weight:600;opacity:.6}' +
'.rtl-value{font-size:20px;font-weight:800;color:var(--rtl-c)}' +
'@keyframes rtl-blink{50%{opacity:.25}}' +
'@media (prefers-reduced-motion:reduce){.rtl-live i{animation:none}}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }
function setup(el) {
var d = el.dataset;
var c = {
el: el,
color: d.color || DEFAULTS.color,
interval: +d.interval || DEFAULTS.interval,
win: Math.max(5, +d.window || DEFAULTS.window),
min: d.min !== undefined ? +d.min : null,
max: d.max !== undefined ? +d.max : null,
pre: d.prefix || '', suf: d.suffix || '',
dec: d.decimals !== undefined ? +d.decimals : DEFAULTS.decimals,
manual: d.source === 'manual',
pts: [], t: 0, visible: false, raf: 0
};
el.classList.add('rtl');
el.style.setProperty('--rtl-c', c.color);
el.innerHTML =
'<canvas aria-hidden="true"></canvas>' +
'<div class="rtl-head"><span class="rtl-live" aria-hidden="true"><i></i>Live</span>' +
(d.label ? '<span class="rtl-label">' + esc(d.label) + '</span>' : '') +
'<span class="rtl-value" role="status" aria-live="off"></span></div>';
c.canvas = el.querySelector('canvas');
c.ctx = c.canvas.getContext('2d');
// The colour as #rrggbb, so the fill under the line can fade it out.
c.ctx.fillStyle = c.color;
c.hex = /^#[0-9a-f]{6}$/i.test(c.ctx.fillStyle) ? c.ctx.fillStyle : DEFAULTS.color;
c.valueEl = el.querySelector('.rtl-value');
el.setAttribute('role', 'figure');
el.setAttribute('aria-label', (d.label ? d.label + ': ' : '') + 'live line chart');
// Start with a full window of history so the chart is never empty.
c.level = c.min !== null && c.max !== null ? (c.min + c.max) / 2 : 50;
if (!c.manual) for (var i = 0; i <= c.win + 1; i++) c.pts.push(fake(c));
el._rtl = c;
resize(c);
if (window.ResizeObserver) new ResizeObserver(function () { resize(c); if (!c.raf) draw(c); }).observe(el);
return c;
}
// A made-up reading: wanders, drifts back towards the middle, and spikes now and then.
function fake(c) {
var lo = c.min !== null ? c.min : 0, hi = c.max !== null ? c.max : 100, span = hi - lo;
var mid = lo + span * 0.45;
c.level += (Math.random() - 0.5) * span * 0.12 + (mid - c.level) * 0.08;
if (Math.random() < 0.06) c.level += span * (0.15 + Math.random() * 0.2);
c.level = Math.max(lo + span * 0.04, Math.min(hi - span * 0.04, c.level));
return c.level;
}
function push(el, v) {
var c = el && el._rtl;
if (!c) return;
// Each fed reading slides in from the right over one interval.
if (c.manual) c.t = 0;
c.pts.push(+v);
if (c.pts.length > c.win + 4) c.pts.splice(0, c.pts.length - (c.win + 4));
if (!c.raf) draw(c);
}
function resize(c) {
var dpr = Math.min(2, window.devicePixelRatio || 1);
c.w = c.el.clientWidth; c.h = c.el.clientHeight;
c.canvas.width = Math.round(c.w * dpr); c.canvas.height = Math.round(c.h * dpr);
c.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function draw(c, dt) {
var ctx = c.ctx, W = c.w, H = c.h, pts = c.pts;
if (!W || !H) return;
if (!pts.length) { ctx.clearRect(0, 0, W, H); c.valueEl.textContent = '-'; return; }
// t runs 0–1 between readings; the newest point slides in from the right.
var t = Math.min(1, c.t);
var n = pts.length;
var head = n > 1 ? pts[n - 2] + (pts[n - 1] - pts[n - 2]) * t : pts[0] || 0;
var lo = c.min, hi = c.max;
if (lo === null || hi === null) {
var a = Math.min.apply(null, pts.concat([head])), b = Math.max.apply(null, pts.concat([head]));
var pad = (b - a) * 0.15 || 1;
if (lo === null) lo = a - pad;
if (hi === null) hi = b + pad;
if (c.lo === undefined || !dt) { c.lo = lo; c.hi = hi; }
var e = dt ? 1 - Math.exp(-dt / 400) : 1;
c.lo += (lo - c.lo) * e; c.hi += (hi - c.hi) * e;
lo = c.lo; hi = c.hi;
}
var top = 36, right = 10, bottom = 4, plotW = W - right, plotH = H - top - bottom;
var y = function (v) { return top + (1 - (v - lo) / (hi - lo || 1)) * plotH; };
var step = plotW / c.win;
// Point i sits at the right edge when it's the head; older ones further left.
var x = function (i) { return plotW - (n - 2 + t - i) * step; };
var ink = getComputedStyle(c.el).color;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = ink;
for (var g = 0; g <= 3; g++) {
ctx.globalAlpha = g === 3 ? 0.2 : 0.07;
ctx.fillRect(0, Math.round(top + (plotH * g) / 3) + 0.5, plotW, 1);
}
ctx.globalAlpha = 1;
// The line up to the head.
ctx.beginPath();
var first = true, i0 = 0;
for (var i = 0; i < n - 1; i++) {
if (x(i + 1) < 0) continue;
if (first) { ctx.moveTo(x(i), y(pts[i])); first = false; i0 = i; }
else ctx.lineTo(x(i), y(pts[i]));
}
var hx = plotW, hy = y(head);
ctx.lineTo(hx, hy);
ctx.save();
ctx.lineTo(hx, H - bottom); ctx.lineTo(x(i0), H - bottom); ctx.closePath();
var grad = ctx.createLinearGradient(0, top, 0, H);
grad.addColorStop(0, c.hex + '55'); grad.addColorStop(1, c.hex + '00');
ctx.fillStyle = grad; ctx.fill();
ctx.restore();
ctx.beginPath();
first = true;
for (i = 0; i < n - 1; i++) {
if (x(i + 1) < 0) continue;
if (first) { ctx.moveTo(x(i), y(pts[i])); first = false; } else ctx.lineTo(x(i), y(pts[i]));
}
ctx.lineTo(hx, hy);
ctx.strokeStyle = c.color; ctx.lineWidth = 2.5; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
ctx.stroke();
// Fade the oldest part out at the left edge.
ctx.save();
ctx.globalCompositeOperation = 'destination-out';
var fade = ctx.createLinearGradient(0, 0, 40, 0);
fade.addColorStop(0, 'rgba(0,0,0,1)'); fade.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = fade; ctx.fillRect(0, 0, 40, H);
ctx.restore();
// The head: a pulsing halo and a dot.
var pulse = (performance.now() % 1400) / 1400;
ctx.fillStyle = c.color;
ctx.globalAlpha = 0.35 * (1 - pulse);
ctx.beginPath(); ctx.arc(hx, hy, 4 + pulse * 10, 0, Math.PI * 2); ctx.fill();
ctx.globalAlpha = 1;
ctx.beginPath(); ctx.arc(hx, hy, 4.5, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(hx, hy, 2, 0, Math.PI * 2); ctx.fill();
c.valueEl.textContent = c.pre + head.toFixed(c.dec) + c.suf;
}
function loop(c) {
var prev = 0;
function frame(now) {
if (!c.visible) { c.raf = 0; return; }
var dt = prev ? Math.min(60, now - prev) : 16;
prev = now;
if (c.manual) c.t = Math.min(1, c.t + dt / c.interval);
else {
c.t += dt / c.interval;
while (c.t >= 1) {
c.t -= 1;
c.pts.push(fake(c));
if (c.pts.length > c.win + 4) c.pts.shift();
}
}
draw(c, dt);
c.raf = requestAnimationFrame(frame);
}
c.raf = requestAnimationFrame(frame);
}
window.animacious = window.animacious || {};
window.animacious.realTimeLineChart = { push: push };
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var c = e.target._rtl;
c.visible = e.isIntersecting;
if (c.visible && !c.raf) loop(c);
});
});
document.querySelectorAll('[data-real-time-line-chart]').forEach(function (el) {
if (el._rtl) return;
var c = setup(el);
c.t = 1;
draw(c);
if (!reduced()) io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div id="cpu" data-real-time-line-chart data-source="manual"
data-label="CPU" data-suffix="%" data-min="0" data-max="100"
style="height: 240px"></div>
<script>
setInterval(function () {
animacious.realTimeLineChart.push(document.getElementById("cpu"), readCpu());
}, 1000);
</script>