← Charts

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

  1. Paste the snippet just before </head>.
  2. Add data-real-time-line-chart to an element with a height. On its own it plots made-up readings.
  3. For real data add data-source="manual" and call animacious.realTimeLineChart.push(el, value) for each reading.

Options

Add any of these attributes to the same element.

data-labelName shown with the reading, e.g. CPU.
data-min / data-maxFix the scale. Default: it eases to fit the data.
data-prefix / data-suffixText around the reading, e.g. %.
data-decimalsDecimal places on the reading. Default 0.
data-colorLine colour. Default #a855f7.
data-intervalTime between made-up readings, and how long each new one takes to slide in. Default 500 ms.
data-windowReadings across the chart. Default 40.
data-sourcemanual 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.

1 · Snippet
<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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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>
2 · Your HTML
<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>