Dot Elastic
Three dots take turns to stretch up tall and thin, then snap back with a little elastic wobble, like pulled taffy. 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-dot-elastic>where the loader should appear.
Options
Add any of these attributes to the same element.
data-dots | Number of dots, 2 to 7. Default 3. |
data-stretch | How tall each dot gets, in dot heights. Default 2.4. |
data-speed | Time for one dot’s stretch. Default 900 ms. |
data-colors | Comma-separated colours, one per dot, instead of color. |
It only moves while it’s on screen, and is marked as a progress bar labelled “Loading” (set your own aria-label). With reduced motion the dots sit still.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Dot Elastic: a row of dots stretch tall and squash back, one after
// another, like taffy being pulled. Sized with font-size, coloured with color.
// It only moves while it's on screen.
// Usage: <div data-dot-elastic aria-label="Loading"></div>
// Optional: data-dots (2 to 7, default 3), data-speed (ms for one dot's
// stretch, default 900), data-stretch (how tall it gets, in dot heights,
// default 2.4), data-colors (comma-separated, one per dot, instead of color).
var DEFAULTS = { dots: 3, speed: 900, stretch: 2.4 };
var css =
'.dte{display:inline-flex;align-items:center;gap:0.3em}' +
'.dte-dot{width:0.42em;height:0.42em;border-radius:0.21em;background:currentColor}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function build(el) {
var d = el.dataset;
var n = Math.max(2, Math.min(7, +d.dots || DEFAULTS.dots));
var stretch = Math.max(1.2, +d.stretch || DEFAULTS.stretch);
var colors = d.colors ? d.colors.split(',') : [];
el.classList.add('dte');
el.setAttribute('role', 'progressbar');
if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Loading');
el.textContent = '';
// Room for the tallest stretch, so the layout never jumps.
el.style.height = 0.42 * stretch + 'em';
var dots = [];
for (var i = 0; i < n; i++) {
var dot = document.createElement('span');
dot.className = 'dte-dot';
if (colors.length) dot.style.background = colors[i % colors.length].trim();
el.appendChild(dot);
dots.push(dot);
}
if (reduced()) return null;
var speed = +d.speed || DEFAULTS.speed;
var stagger = speed * 0.22;
// Every dot shares one cycle: its own stretch, then a rest while the
// others take their turn.
var rest = Math.max(speed * 0.25, stagger * n - speed * 0.6);
var tall = 0.42 * stretch + 'em';
return dots.map(function (dot, i) {
return anime.animate(dot, {
// Height (not scale) so the rounded ends stay round.
height: [
{ to: tall, duration: speed * 0.4, ease: 'out(3)' },
{ to: '0.32em', duration: speed * 0.35, ease: 'outElastic(1, .5)' },
{ to: '0.42em', duration: speed * 0.25, ease: 'out(2)' }
],
width: [
{ to: '0.3em', duration: speed * 0.4, ease: 'out(3)' },
{ to: '0.5em', duration: speed * 0.35, ease: 'outElastic(1, .5)' },
{ to: '0.42em', duration: speed * 0.25, ease: 'out(2)' }
],
delay: i * stagger,
loopDelay: rest,
loop: true,
autoplay: false
});
});
}
function init() {
document.querySelectorAll('[data-dot-elastic]').forEach(function (el) {
if (el._dte) return;
el._dte = true;
var anims = build(el);
if (!anims) return;
new IntersectionObserver(function (entries) {
anims.forEach(function (a) { entries[0].isIntersecting ? a.play() : a.pause(); });
}).observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-dot-elastic style="font-size: 24px; color: #6a0dad"></div>