Endless Stairs
A ball bounces down a staircase, one step at a time, squashing as it lands and stretching as it falls. The stairs scroll up to meet it and fade out at the edges, so it never reaches the bottom. 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-endless-stairs>where the loader should appear.
Options
Add any of these attributes to the same element.
data-speed | Time for each step. Default 560 ms. |
data-ball | Ball colour. Default color. |
data-direction="left" | Go down to the left instead of the right. |
It’s 3.2em wide and 2.4em tall. It only moves while it’s on screen. With reduced motion the ball hangs still mid-hop.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Endless Stairs: a ball bounces down a staircase, one step at a time,
// squashing as it lands. The stairs scroll up to meet it, so it never
// reaches the bottom, and they fade out at the edges.
// Sized with font-size (3.2em wide, 2.4em tall), coloured with color.
// It only moves while it's on screen.
// Usage: <div data-endless-stairs aria-label="Loading"></div>
// Optional: data-speed (ms for each step, default 560), data-ball (ball
// colour, default color), data-direction="left" (go down to the left).
var DEFAULTS = { speed: 560 };
var W = 16, H = 10, X0 = 36, Y0 = 36, BR = 4.6, HOP = 9;
var css =
'.ens{display:inline-block;width:3.2em;height:2.4em;vertical-align:middle;' +
'-webkit-mask-image:radial-gradient(closest-side,#000 55%,transparent);mask-image:radial-gradient(closest-side,#000 55%,transparent)}' +
'.ens svg{display:block;width:100%;height:100%}' +
'.ens-fill{fill:currentColor;opacity:.13}' +
'.ens-edge{fill:none;stroke:currentColor;stroke-width:1.6;stroke-linejoin:round;opacity:.55}' +
'.ens-ball{fill:currentColor}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
var NS = 'http://www.w3.org/2000/svg';
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function make(parent, tag, attrs) {
var n = document.createElementNS(NS, tag);
for (var k in attrs) n.setAttribute(k, attrs[k]);
parent.appendChild(n);
return n;
}
function build(el) {
var d = el.dataset;
el.classList.add('ens');
if (!el.getAttribute('role')) el.setAttribute('role', 'progressbar');
if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Loading');
el.textContent = '';
var svg = make(el, 'svg', { viewBox: '0 0 80 60', 'aria-hidden': 'true' });
var flip = make(svg, 'g', d.direction === 'left' ? { transform: 'translate(80 0) scale(-1 1)' } : {});
// Steps k = -4…5 in world units: step k's tread runs from x = k*W to
// (k+1)*W at height y = k*H. One step later the picture is identical.
var edge = 'M' + (-4 * W) + ' ' + (-5 * H), k;
for (k = -4; k <= 5; k++) edge += 'V' + (k * H) + 'H' + ((k + 1) * W);
var stairs = make(flip, 'g', {});
make(stairs, 'path', { class: 'ens-fill', d: edge + 'V' + (12 * H) + 'H' + (-4 * W) + 'Z' });
make(stairs, 'path', { class: 'ens-edge', d: edge });
var ball = make(flip, 'g', {});
var dot = make(ball, 'circle', { class: 'ens-ball', cx: 0, cy: -BR, r: BR });
if (d.ball) dot.style.fill = d.ball;
function draw(u) {
// The camera follows the ball down at a steady rate; the ball hops in an arc above that line.
stairs.setAttribute('transform', 'translate(' + (X0 - W / 2 - u * W).toFixed(2) + ' ' + (Y0 - u * H).toFixed(2) + ')');
var lift = 4 * HOP * u * (1 - u);
var q = Math.max(0, 1 - Math.min(u, 1 - u) / 0.09); // squash near each landing
var v = Math.abs(1 - 2 * u) * (1 - q); // stretch when moving fast
var sy = (1 - 0.3 * q) * (1 + 0.1 * v), sx = (1 + 0.24 * q) * (1 - 0.06 * v);
ball.setAttribute('transform', 'translate(' + X0 + ' ' + (Y0 - lift).toFixed(2) + ') scale(' + sx.toFixed(3) + ' ' + sy.toFixed(3) + ')');
}
var speed = Math.max(200, +d.speed || DEFAULTS.speed);
draw(0.5);
if (reduced()) return null;
var t = speed * 0.5, last = 0;
return anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
t += last ? Math.min(50, now - last) : 16;
last = now;
draw((t % speed) / speed);
},
onPause: function () { last = 0; },
});
}
function init() {
document.querySelectorAll('[data-endless-stairs]').forEach(function (el) {
if (el._ens) return;
el._ens = true;
var timer = build(el);
if (!timer) return;
new IntersectionObserver(function (entries) {
entries[0].isIntersecting ? timer.play() : timer.pause();
}).observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-endless-stairs style="font-size: 24px; color: #6a0dad" aria-label="Loading"></div>