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
- Paste the snippet just before
</head>. - Add
data-scatter-plot-pop-in="x,y x,y …"to an element with a height, listing each point asx,y, orx,y,groupto colour it by group (0, 1, …).
Options
Add any of these attributes to the same element.
data-groups | Comma-separated group names, shown as a legend. |
data-colors | Comma-separated colours, one per group. Default purple, pink, blue… |
data-trend | Draw a best-fit line through each group once the points are in. |
data-x-label / data-y-label | Axis titles. |
data-ticks | false hides the axis numbers. |
data-order | x pops the points in from left to right. Default: a random order. |
data-duration | Time for all the points to arrive. Default 1400 ms. |
data-points | With the attribute left empty, it plots this many made-up points. Default 40. |
data-loop | Clear and pop in again every few seconds. |
<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 { '&': '&', '<': '<', '>': '>', '"': '"' }[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><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>