Pyramid Build
Layers stack up into a pyramid. The base drops into place first, then each layer above lands on top with a little settle and its label fading in, until the peak is on.
Purpose
Esteem
Belonging
Safety
Food, water, rest
How to use it
- Paste the snippet just before
</head>. - Add
data-pyramid-buildto a container with one child per layer, the top layer first.
Options
Add any of these attributes to the same element.
data-colors | Comma-separated layer colours, top first. Default: shades of purple, darker towards the base. |
data-stagger | Gap between layers landing. Default 280 ms. |
data-loop | Lift off and stack up again. |
It scales to the width of its container. Labels shrink to fit their layer, so keep the top one short.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Pyramid Build: layers stack up into a pyramid. The base drops into place
// first, then each layer above it lands on top with a little settle, its
// label fading in, until the peak is on. List the layers top first, as
// you'd read them.
// Usage:
// <div data-pyramid-build>
// <span>Purpose</span>
// <span>Growth</span>
// <span>Belonging</span>
// <span>Safety</span>
// <span>Basics</span>
// </div>
// Optional: data-colors (comma-separated, top layer first; default shades
// of purple), data-stagger (ms between layers, default 280), data-loop
// (lift off and stack up again).
var DEFAULTS = { top: [168, 85, 247], bottom: [76, 29, 149], stagger: 280, hold: 2400 };
var NS = 'http://www.w3.org/2000/svg';
var css =
'.pyb{display:flex;align-items:center;justify-content:center}' +
'.pyb-svg{display:block;width:100%;height:auto;max-height:100%;overflow:visible}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function on(v) { return v !== undefined && v !== 'false'; }
function mk(tag, attrs, parent) {
var n = document.createElementNS(NS, tag);
for (var k in attrs) n.setAttribute(k, attrs[k]);
if (parent) parent.appendChild(n);
return n;
}
function shade(t) {
var c = DEFAULTS.top.map(function (a, i) { return Math.round(a + (DEFAULTS.bottom[i] - a) * t); });
return 'rgb(' + c.join(',') + ')';
}
function build(el) {
var d = el.dataset;
var names = Array.from(el.children).map(function (c) { return c.textContent.trim(); });
var n = names.length;
var colors = d.colors ? d.colors.split(',').map(function (c) { return c.trim(); }) : null;
var W = 320, lh = Math.min(48, 250 / Math.max(1, n)), gap = 4;
var H = n * lh + (n - 1) * gap, T = 30, B = 156;
function hw(y) { return T + ((B - T) * y) / H; }
el.setAttribute('role', 'img');
if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Pyramid, from the top: ' + names.join(', '));
el.textContent = '';
el.classList.add('pyb');
var svg = mk('svg', { class: 'pyb-svg', viewBox: '0 0 ' + W + ' ' + H, 'aria-hidden': 'true' }, el);
var layers = names.map(function (name, i) {
var y0 = i * (lh + gap), y1 = y0 + lh, cx = W / 2;
var g = mk('g', {}, svg);
var fill = colors ? colors[i % colors.length] : shade(n > 1 ? i / (n - 1) : 0);
mk('polygon', { points: [cx - hw(y0), y0, cx + hw(y0), y0, cx + hw(y1), y1, cx - hw(y1), y1].join(' '), fill: fill, 'stroke-linejoin': 'round' }, g);
var t = mk('text', { x: cx, y: (y0 + y1) / 2, 'text-anchor': 'middle', 'dominant-baseline': 'central', 'font-size': Math.min(15, lh * 0.38), 'font-weight': 700, fill: '#fff' }, g);
t.textContent = name;
var room = hw(y0) * 2 - 14, w = 0;
try { w = t.getComputedTextLength(); } catch (e) {}
if (w > room) t.setAttribute('font-size', Math.max(8, (Math.min(15, lh * 0.38) * room) / w));
return { g: g, label: t };
});
return { layers: layers, loop: on(d.loop), done: false, visible: false };
}
function hide(s) {
s.layers.forEach(function (l) {
anime.utils.set(l.g, { y: -60, opacity: 0 });
anime.utils.set(l.label, { opacity: 0 });
});
}
function start(el) {
var s = el._pyb;
var stagger = +el.dataset.stagger || DEFAULTS.stagger;
hide(s);
var tl = anime.createTimeline({
onComplete: function () {
if (s.tl !== tl) return;
if (s.loop) start(el); else s.done = true;
}
});
s.tl = tl;
var n = s.layers.length;
// Bottom layer first.
s.layers.slice().reverse().forEach(function (l, k) {
var at = k * stagger;
tl.add(l.g, { y: [-60, 0], duration: 620, ease: 'outBack(1.3)' }, at);
tl.add(l.g, { opacity: [0, 1], duration: 260, ease: 'out(2)' }, at);
tl.add(l.label, { opacity: [0, 1], duration: 350, ease: 'out(2)' }, at + 350);
});
var end = (n - 1) * stagger + 700;
if (s.loop) {
var back = end + DEFAULTS.hold;
s.layers.forEach(function (l, i) {
tl.add(l.g, { y: -40, opacity: 0, duration: 420, ease: 'in(2)' }, back + i * 90);
});
}
}
function init() {
var still = reduced();
document.querySelectorAll('[data-pyramid-build]').forEach(function (el) {
if (el._pyb) return;
var s = (el._pyb = build(el));
if (still) return;
hide(s);
new IntersectionObserver(function (entries) {
s.visible = entries[0].isIntersecting;
if (s.visible) { if (!s.tl) start(el); else if (!s.done) s.tl.resume(); }
else if (s.tl && !s.done) s.tl.pause();
}, { threshold: 0.2 }).observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-pyramid-build style="max-width: 360px">
<span>Purpose</span>
<span>Growth</span>
<span>Belonging</span>
<span>Safety</span>
<span>Basics</span>
</div>