Gooey Dots
Dots that merge and pull apart like drops of liquid. They run together into one blob, reaching for each other with a stretchy neck, then spring apart again. Or let one dot glide along the row and back, swallowing and leaving each dot it passes. Size it with font-size and colour it with color.
How to use it
- Paste the snippet just before
</head>. - Add an empty
<div data-gooey-dots>where the loader should appear.
Options
Add any of these attributes to the same element.
data-style="shuttle" | One dot glides along the row and back through the others. |
data-dots | Number of dots, 3 to 6. Default 3 (4 for shuttle). |
data-speed | Time for one cycle. Default 1800 ms. |
data-colors | Comma-separated colours, one per dot. Where they merge the colours meet edge to edge. |
The goo is an SVG blur-and-contrast filter, sized from the loader’s font-size when it starts. It only moves while it’s on screen. With reduced motion the dots sit still in a row.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Gooey Dots: dots that merge and pull apart like drops of liquid. A blur
// and contrast filter makes them reach for each other with a stretchy neck
// as they meet and snap apart as they separate. Sized with font-size,
// coloured with color. It only moves while it's on screen.
// Usage: <div data-gooey-dots aria-label="Loading"></div>
// Optional: data-style ("merge", the default: the dots run together into
// one blob and spring apart; "shuttle": one dot glides along the row and
// back, swallowing and leaving each dot it passes), data-dots (3 to 6,
// default 3 for merge, 4 for shuttle), data-speed (ms for one cycle,
// default 1800), data-colors (comma-separated, one per dot).
var DEFAULTS = { style: 'merge', speed: 1800 };
var DOT = 0.6, GAP = 1, PAD = 0.4; // em
var css =
'.gdt{display:inline-block;vertical-align:middle}' +
'.gdt-goo{position:relative;width:100%;height:100%}' +
'.gdt-d{position:absolute;top:50%;left:50%;width:' + DOT + 'em;height:' + DOT + 'em;margin:' + -DOT / 2 + 'em 0 0 ' + -DOT / 2 + 'em;border-radius:50%;background:currentColor}' +
'.gdt-svg{position:absolute;width:0;height:0;overflow:hidden}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
var NS = 'http://www.w3.org/2000/svg';
var uid = 0;
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function build(el) {
var d = el.dataset;
var shuttle = d.style === 'shuttle';
var n = Math.max(3, Math.min(6, Math.round(+d.dots) || (shuttle ? 4 : 3)));
var speed = Math.max(400, +d.speed || DEFAULTS.speed);
var colors = d.colors ? d.colors.split(',').map(function (c) { return c.trim(); }) : [];
var id = 'gdt-goo-' + (++uid);
el.classList.add('gdt');
if (!el.getAttribute('role')) el.setAttribute('role', 'progressbar');
if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Loading');
el.textContent = '';
el.style.width = ((shuttle ? n : n - 1) * GAP + DOT + PAD * 2) + 'em';
el.style.height = (DOT + PAD * 2) + 'em';
// The goo filter: blur the dots together, then sharpen the edge back up.
var blur = parseFloat(getComputedStyle(el).fontSize) * 0.15;
var svg = document.createElementNS(NS, 'svg');
svg.setAttribute('class', 'gdt-svg');
svg.setAttribute('aria-hidden', 'true');
svg.innerHTML = '<filter id="' + id + '" x="-30%" y="-80%" width="160%" height="260%" color-interpolation-filters="sRGB">' +
'<feGaussianBlur in="SourceGraphic" stdDeviation="' + blur.toFixed(2) + '" result="b"/>' +
'<feColorMatrix in="b" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 22 -10"/></filter>';
el.appendChild(svg);
var goo = document.createElement('span');
goo.className = 'gdt-goo';
goo.style.display = 'block';
goo.style.filter = 'url(#' + id + ')';
goo.setAttribute('aria-hidden', 'true');
el.appendChild(goo);
// In shuttle mode the dots rest in slots 1..n-1 of n+1 and the traveller
// goes from slot 0 to slot n and back.
var slots = shuttle ? n + 1 : n;
var dots = [], home = [];
for (var j = 0; j < slots; j++) home.push((j - (slots - 1) / 2) * GAP);
for (var i = 0; i < n; i++) {
var dot = document.createElement('span');
dot.className = 'gdt-d';
if (colors[i]) dot.style.background = colors[i];
goo.appendChild(dot);
anime.utils.set(dot, { x: home[i] + 'em' });
dots.push(dot);
}
if (reduced()) return null;
var tl = anime.createTimeline({ loop: true, autoplay: false });
if (shuttle) {
// The first dot travels from one end to the other and back, through
// the dots resting in between.
var mover = dots[0], far = home[n], near = home[0];
tl.add(mover, { x: far + 'em', duration: speed / 2, ease: 'inOut(2.5)' }, 0)
.add(mover, { x: near + 'em', duration: speed / 2, ease: 'inOut(2.5)' }, speed / 2);
// Each resting dot swells a little as the traveller passes through it.
for (var k = 1; k < n; k++) {
var a = (home[k] - near) / (far - near);
// When does an inOut(2.5) glide reach this fraction of the way?
var f = a < 0.5 ? Math.pow(2 * a, 1 / 2.5) / 2 : 1 - Math.pow(2 - 2 * a, 1 / 2.5) / 2;
[f * speed / 2, speed - f * speed / 2].forEach(function (t) {
tl.add(dots[k], { scale: [{ to: 1.3, duration: speed * 0.05, ease: 'out(2)' }, { to: 1, duration: speed * 0.12, ease: 'out(3)' }] }, Math.max(0, t - speed * 0.04));
});
}
} else {
// Crowd in to the middle, wobble there as one blob, then spring apart.
var mid = speed * 0.38, hold = speed * 0.14;
dots.forEach(function (dot, i) {
var lag = Math.abs(home[i]) / GAP * speed * 0.04;
tl.add(dot, { x: home[i] * 0.22 + 'em', scale: 1.1, duration: mid, ease: 'inOut(3)' }, lag)
.add(dot, { x: home[i] + 'em', scale: 1, duration: speed - mid - hold - lag, ease: 'outElastic(1, .55)' }, mid + hold + lag);
});
}
return tl;
}
function init() {
document.querySelectorAll('[data-gooey-dots]').forEach(function (el) {
if (el._gdt) return;
el._gdt = true;
var tl = build(el);
if (!tl) return;
new IntersectionObserver(function (entries) {
entries[0].isIntersecting ? tl.play() : tl.pause();
}).observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-gooey-dots style="font-size: 24px; color: #6a0dad" aria-label="Loading"></div>
<!-- one dot shuttling through the others: -->
<div data-gooey-dots data-style="shuttle"></div>