← Charts

Scatter Plot Pop-in

The points of a scatter plot drop in one by one in a random order, pop past their size and settle into place with a little bounce. Then a best-fit line can draw itself through each group.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-scatter-plot-pop-in="x,y x,y …" to an element with a height, listing each point as x,y, or x,y,group to colour it by group (0, 1, …).

Options

Add any of these attributes to the same element.

data-groupsComma-separated group names, shown as a legend.
data-colorsComma-separated colours, one per group. Default purple, pink, blue…
data-trendDraw a best-fit line through each group once the points are in.
data-x-label / data-y-labelAxis titles.
data-ticksfalse hides the axis numbers.
data-orderx pops the points in from left to right. Default: a random order.
data-durationTime for all the points to arrive. Default 1400 ms.
data-pointsWith the attribute left empty, it plots this many made-up points. Default 40.
data-loopClear and pop in again every few seconds.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Scatter Plot Pop-in: the points of a scatter plot drop in one by one,
    // pop past their size and settle into place, then a trend line can draw
    // itself through them.
    // Usage: <div data-scatter-plot-pop-in="12,30 25,41 40,38 55,62" style="height:240px"></div>
    // Each point is "x,y", or "x,y,group" to colour points by group (0, 1, …).
    // Leave the attribute empty to plot made-up sample data instead
    // (data-points sets how many, default 40).
    // Optional: data-groups="Mobile,Desktop" (group names, shown as a legend),
    // data-colors="#a855f7,#ec4899,…", data-trend (draw a best-fit line
    // through each group), data-x-label / data-y-label (axis titles),
    // data-ticks="false" (no axis numbers), data-order="x" (left to right
    // instead of a random order), data-duration (ms for all the points to
    // arrive, default 1400), data-loop (clear and pop in again).
    var DEFAULTS = { points: 40, duration: 1400, hold: 2600 };
    var PALETTE = ['#a855f7', '#ec4899', '#0ea5e9', '#f59e0b', '#10b981', '#6366f1'];

    var css =
      '.spp{position:relative;display:grid;grid-template-columns:auto 1fr;grid-template-rows:1fr auto auto;font-size:11px;font-variant-numeric:tabular-nums}' +
      '.spp-plot{position:relative;grid-column:2;grid-row:1;min-height:0;border-left:1px solid;border-bottom:1px solid;border-color:color-mix(in srgb,currentColor 30%,transparent)}' +
      '.spp-grid{position:absolute;left:0;right:0;border-top:1px dashed color-mix(in srgb,currentColor 12%,transparent)}' +
      '.spp-gridx{position:absolute;top:0;bottom:0;border-left:1px dashed color-mix(in srgb,currentColor 12%,transparent)}' +
      '.spp-yt{position:absolute;right:6px;transform:translateY(-50%);opacity:.55}' +
      '.spp-xt{position:absolute;top:4px;transform:translateX(-50%);opacity:.55}' +
      '.spp-yaxis{grid-column:1;grid-row:1;position:relative;min-width:0}' +
      '.spp-xaxis{grid-column:2;grid-row:2;position:relative;height:18px}' +
      '.spp-ylabel{position:absolute;left:0;top:50%;transform-origin:0 0;transform:rotate(-90deg) translateX(-50%);white-space:nowrap;font-weight:600;opacity:.7}' +
      '.spp-xlabel{grid-column:2;grid-row:3;text-align:center;font-weight:600;opacity:.7;padding-top:2px}' +
      '.spp-pt{position:absolute;width:10px;height:10px;margin:-5px 0 0 -5px;border-radius:50%;background:var(--spp-c);' +
      'box-shadow:0 0 0 1.5px rgba(255,255,255,.8),0 2px 6px color-mix(in srgb,var(--spp-c) 45%,transparent)}' +
      '.spp-plot svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible;pointer-events:none}' +
      '.spp-legend{position:absolute;top:0;right:0;display:flex;gap:10px;font-weight:600;background:rgba(255,255,255,.7);padding:2px 6px;border-radius:6px}' +
      '.spp-legend span{display:inline-flex;align-items:center;gap:5px}' +
      '.spp-legend i{width:8px;height:8px;border-radius:50%;background:var(--spp-c)}';
    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]; }); }

    // Made-up but believable data: a rising cloud of points per group.
    function sample(count, groups) {
      var seed = 7;
      function rnd() { seed = (seed * 16807) % 2147483647; return (seed - 1) / 2147483646; }
      function gauss() { return (rnd() + rnd() + rnd() - 1.5) / 1.5; }
      var pts = [];
      for (var i = 0; i < count; i++) {
        var g = i % groups, x = 6 + rnd() * 88;
        var y = 12 + x * (0.72 - g * 0.22) + g * 6 + gauss() * 13;
        pts.push([Math.round(x * 10) / 10, Math.round(Math.max(2, Math.min(98, y)) * 10) / 10, g]);
      }
      return pts;
    }

    // Round numbers for the axis: a step of 1, 2 or 5 × a power of ten.
    function ticks(lo, hi) {
      var raw = (hi - lo) / 4, p = Math.pow(10, Math.floor(Math.log10(raw || 1)));
      var step = [1, 2, 2.5, 5, 10].map(function (m) { return m * p; }).filter(function (s) { return s >= raw; })[0];
      var start = Math.floor(lo / step) * step, end = Math.ceil(hi / step) * step;
      return { lo: start, hi: end, step: step };
    }

    function setup(el) {
      var d = el.dataset;
      var groups = d.groups ? d.groups.split(',') : [];
      var colors = d.colors ? d.colors.split(',') : PALETTE;
      var attr = el.getAttribute('data-scatter-plot-pop-in').trim();
      var pts = attr
        ? attr.split(/\s+/).map(function (p) { var a = p.split(',').map(Number); return [a[0], a[1], a[2] || 0]; })
        : sample(+d.points || DEFAULTS.points, Math.max(1, groups.length));
      var xs = pts.map(function (p) { return p[0]; }), ys = pts.map(function (p) { return p[1]; });
      var tx = ticks(Math.min(0, Math.min.apply(null, xs)), Math.max.apply(null, xs));
      var ty = ticks(Math.min(0, Math.min.apply(null, ys)), Math.max.apply(null, ys));
      function px(x) { return ((x - tx.lo) / (tx.hi - tx.lo)) * 100; }
      function py(y) { return 100 - ((y - ty.lo) / (ty.hi - ty.lo)) * 100; }
      var showTicks = d.ticks !== 'false';

      el.classList.add('spp');
      el.setAttribute('role', 'img');
      el.setAttribute('aria-label', 'Scatter plot of ' + pts.length + ' points' +
        (d.xLabel ? ', ' + d.xLabel + ' against ' + (d.yLabel || 'y') : '') + '.');

      var plot = '<div class="spp-plot">', yAxis = '', xAxis = '', v;
      for (v = ty.lo + ty.step; v <= ty.hi + 1e-9; v += ty.step) plot += '<span class="spp-grid" style="top:' + py(v) + '%"></span>';
      for (v = tx.lo + tx.step; v <= tx.hi + 1e-9; v += tx.step) plot += '<span class="spp-gridx" style="left:' + px(v) + '%"></span>';
      if (showTicks) {
        for (v = ty.lo; v <= ty.hi + 1e-9; v += ty.step) yAxis += '<span class="spp-yt" style="top:' + py(v) + '%">' + +v.toFixed(6) + '</span>';
        for (v = tx.lo; v <= tx.hi + 1e-9; v += tx.step) xAxis += '<span class="spp-xt" style="left:' + px(v) + '%">' + +v.toFixed(6) + '</span>';
      }

      // A best-fit line per group.
      var lines = '';
      if (d.trend !== undefined && d.trend !== 'false') {
        var gs = {};
        pts.forEach(function (p) { (gs[p[2]] = gs[p[2]] || []).push(p); });
        Object.keys(gs).forEach(function (g) {
          var a = gs[g], n = a.length, sx = 0, sy = 0, sxx = 0, sxy = 0;
          if (n < 2) return;
          a.forEach(function (p) { sx += p[0]; sy += p[1]; sxx += p[0] * p[0]; sxy += p[0] * p[1]; });
          var m = (n * sxy - sx * sy) / (n * sxx - sx * sx || 1), b = (sy - m * sx) / n;
          var x0 = Math.min.apply(null, a.map(function (p) { return p[0]; })), x1 = Math.max.apply(null, a.map(function (p) { return p[0]; }));
          lines += '<line class="spp-trend" x1="' + px(x0) + '" y1="' + py(m * x0 + b) + '" x2="' + px(x1) + '" y2="' + py(m * x1 + b) +
            '" stroke="' + colors[g % colors.length] + '" stroke-width="2.5" stroke-linecap="round" stroke-dasharray="1 0" vector-effect="non-scaling-stroke"/>';
        });
      }
      plot += '<svg viewBox="0 0 100 100" preserveAspectRatio="none">' + lines + '</svg>';
      plot += pts.map(function (p) {
        return '<span class="spp-pt" style="left:' + px(p[0]) + '%;top:' + py(p[1]) + '%;--spp-c:' + colors[p[2] % colors.length] + '"></span>';
      }).join('');
      if (groups.length > 1) {
        plot += '<div class="spp-legend">' + groups.map(function (g, i) { return '<span style="--spp-c:' + colors[i % colors.length] + '"><i></i>' + esc(g) + '</span>'; }).join('') + '</div>';
      }
      plot += '</div>';

      el.innerHTML = '<div class="spp-yaxis" aria-hidden="true" style="width:' + ((showTicks ? 30 : 0) + (d.yLabel ? 18 : 0)) + 'px">' + yAxis +
        (d.yLabel ? '<span class="spp-ylabel">' + esc(d.yLabel) + '</span>' : '') + '</div>' +
        plot.replace('<div class="spp-plot">', '<div class="spp-plot" aria-hidden="true">') +
        (showTicks ? '<div class="spp-xaxis" aria-hidden="true">' + xAxis + '</div>' : '') +
        (d.xLabel ? '<div class="spp-xlabel" aria-hidden="true">' + esc(d.xLabel) + '</div>' : '');

      var c = {
        el: el,
        pts: Array.prototype.slice.call(el.querySelectorAll('.spp-pt')),
        xs: xs,
        trends: Array.prototype.slice.call(el.querySelectorAll('.spp-trend'))
      };
      el._spp = c;
      return c;
    }

    function order(c) {
      var idx = c.pts.map(function (p, i) { return i; });
      if (c.el.dataset.order === 'x') idx.sort(function (a, b) { return c.xs[a] - c.xs[b]; });
      else for (var i = idx.length - 1; i > 0; i--) { var j = Math.floor(Math.random() * (i + 1)), t = idx[i]; idx[i] = idx[j]; idx[j] = t; }
      var rank = [];
      idx.forEach(function (p, r) { rank[p] = r; });
      return rank;
    }

    function hide(c) {
      anime.utils.set(c.pts, { scale: 0, opacity: 0 });
      c.trends.forEach(function (t) { anime.utils.set(anime.svg.createDrawable(t), { draw: '0 0' }); });
    }

    function play(c) {
      var d = c.el.dataset;
      var duration = +d.duration || DEFAULTS.duration;
      var rank = order(c);
      var gap = duration / Math.max(1, c.pts.length);
      hide(c);
      anime.animate(c.pts, {
        opacity: { to: 1, duration: 120 },
        scale: [{ from: 0, to: 1.6, duration: 220, ease: 'out(3)' }, { to: 1, duration: 650, ease: 'outElastic(1, .45)' }],
        y: { from: function () { return -10 - Math.random() * 16; }, to: 0, duration: 520, ease: 'outBounce' },
        delay: function (p, i) { return rank[i] * gap; }
      });
      var lineAt = duration + 500;
      c.trends.forEach(function (t, i) {
        anime.animate(anime.svg.createDrawable(t), { draw: ['0 0', '0 1'], duration: 900, delay: lineAt + i * 200, ease: 'inOut(2)' });
      });
      if (d.loop !== undefined && d.loop !== 'false') {
        setTimeout(function () {
          c.trends.forEach(function (t) { anime.animate(anime.svg.createDrawable(t), { draw: '1 1', duration: 400, ease: 'in(2)' }); });
          anime.animate(c.pts, {
            scale: 0, opacity: 0, duration: 260, ease: 'in(2)',
            delay: anime.stagger(Math.min(15, 600 / c.pts.length), { from: 'random' }),
            onComplete: function () { play(c); }
          });
        }, lineAt + 900 + DEFAULTS.hold);
      }
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          play(e.target._spp);
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-scatter-plot-pop-in]').forEach(function (el) {
        if (el._spp) return;
        var c = setup(el);
        if (reduced()) return;
        hide(c);
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-scatter-plot-pop-in="12,30 25,41 40,38 55,62 70,58 84,77"
     data-x-label="Ad spend" data-y-label="Sales"
     data-trend
     style="height: 260px"></div>