Shape Morph Loader
A shape hops and morphs from circle to square to triangle and back, turning and changing colour as it goes, while its shadow shrinks and grows beneath it. A playful loader for splash screens and empty states. Size it with font-size.
Loading your workspace…
How to use it
- Paste the snippet just before
</head>. - Add an empty
<div data-shape-morph-loader>where the loader should appear.
Options
Add any of these attributes to the same element.
data-colors | Three colours, one per shape. Default #a855f7,#ec4899,#f59e0b. |
data-speed | Time for each morph. Default 650 ms. |
data-hold | How long each shape stays. Default 300 ms. |
data-shadow="false" | No shadow underneath. |
It only morphs while it’s on screen. With reduced motion it shows a still circle.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Shape Morph Loader: a shape hops and morphs from circle to square to
// triangle and back, turning and changing colour as it goes, while its
// shadow shrinks and grows beneath it. Sized with font-size.
// It morphs while it's on screen.
// Usage: <div data-shape-morph-loader aria-label="Loading"></div>
// Optional: data-colors (three colours, one per shape, default
// #a855f7,#ec4899,#f59e0b), data-speed (ms per morph, default 650),
// data-hold (ms on each shape, default 300), data-shadow="false" (no shadow).
var DEFAULTS = { colors: '#a855f7,#ec4899,#f59e0b', speed: 650, hold: 300 };
var N = 72; // points per outline: corners of the square and triangle land on them exactly
var css =
'.sml{display:inline-grid;justify-items:center;gap:0.2em;vertical-align:middle}' +
'.sml svg{display:block;width:2em;height:2em;overflow:visible;transform-origin:50% 100%}' +
'.sml path{stroke-width:7;stroke-linejoin:round;transform-origin:50px 50px}' +
'.sml-shadow{width:1.5em;height:0.2em;border-radius:50%;background:currentColor;opacity:.14}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
// Every shape is traced the same way (N points round the centre, starting
// straight up), so one morphs into another point for point.
function outline(radius) {
var pts = [];
for (var i = 0; i < N; i++) {
var a = -Math.PI / 2 + (i / N) * Math.PI * 2;
var r = radius(a);
pts.push((50 + r * Math.cos(a)).toFixed(2) + ' ' + (50 + r * Math.sin(a)).toFixed(2));
}
return 'M' + pts.join(' L') + ' Z';
}
var SHAPES = [
outline(function () { return 40; }),
outline(function (a) { return 35 / Math.max(Math.abs(Math.cos(a)), Math.abs(Math.sin(a))); }),
// An upright triangle, centred on its middle: 23 from the centre to each edge.
outline(function (a) {
var deg = (a * 180) / Math.PI;
var off = ((((deg - 30) % 120) + 120) % 120) - 60;
return 23 / Math.cos((off * Math.PI) / 180);
})
];
function build(el) {
var d = el.dataset;
var colors = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return c.trim(); });
while (colors.length < 3) colors.push(colors[colors.length - 1]);
el.classList.add('sml');
if (!el.getAttribute('role')) el.setAttribute('role', 'progressbar');
if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Loading');
el.innerHTML = '<svg viewBox="0 0 100 100" aria-hidden="true"><path/></svg>' +
(d.shadow === 'false' ? '' : '<span class="sml-shadow" aria-hidden="true"></span>');
var svg = el.firstChild;
var path = svg.firstChild;
var shadow = el.querySelector('.sml-shadow');
path.setAttribute('d', SHAPES[0]);
path.setAttribute('fill', colors[0]);
path.setAttribute('stroke', colors[0]);
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return null;
var speed = +d.speed || DEFAULTS.speed;
var step = speed + (+d.hold || DEFAULTS.hold);
// Each hop turns the shape on: the square lands at 180°, the triangle at 360°
// (both upright), and the circle can land anywhere, so the loop restarts
// from 0° without a jump.
var turns = [180, 360, 480];
var tl = anime.createTimeline({ loop: true, autoplay: false });
for (var i = 0; i < 3; i++) {
var next = (i + 1) % 3;
var at = i * step;
tl.add(path, { d: SHAPES[next], fill: colors[next], stroke: colors[next], duration: speed, ease: 'inOut(3)' }, at);
tl.add(path, {
rotate: turns[i],
duration: speed,
ease: 'inOut(2.4)'
}, at);
tl.add(svg, {
translateY: [{ to: '-0.35em', duration: speed * 0.5, ease: 'out(2)' }, { to: '0em', duration: speed * 0.5, ease: 'in(2)' }],
scaleY: [{ to: 1, duration: speed }, { to: 0.86, duration: step * 0.12, ease: 'out(2)' }, { to: 1, duration: step * 0.2, ease: 'out(2)' }]
}, at);
if (shadow) {
tl.add(shadow, {
scale: [{ to: 0.55, duration: speed * 0.5, ease: 'out(2)' }, { to: 1, duration: speed * 0.5, ease: 'in(2)' }],
opacity: [{ to: 0.07, duration: speed * 0.5, ease: 'out(2)' }, { to: 0.14, duration: speed * 0.5, ease: 'in(2)' }]
}, at);
}
}
return tl;
}
function init() {
document.querySelectorAll('[data-shape-morph-loader]').forEach(function (el) {
if (el._sml) return;
el._sml = true;
var tl = build(el);
if (!tl) return;
// Only morph while visible, to save battery.
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-shape-morph-loader style="font-size: 32px" aria-label="Loading"></div>