Bouncing Ball
A ball drops, squashes flat as it hits the floor, stretches as it springs back up and hangs for a moment at the top. Its shadow grows and darkens as it falls and shrinks and fades as it rises. Give it a list of colours and it changes on every bounce. 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-bouncing-ball>where the loader should appear.
Options
Add any of these attributes to the same element.
data-speed | Time for one bounce. Default 900 ms. |
data-height | How high it bounces, in ball widths. Default 2. |
data-squash | How flat it gets on landing, 0 to 0.6. Default 0.3. |
data-colors | Comma-separated colours: the ball takes the next one on every bounce. |
The ball is 0.8em across. It only bounces while it’s on screen. With reduced motion the ball rests on the floor.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Bouncing Ball: a ball drops, squashes flat as it hits the floor,
// stretches as it springs back up and hangs for a moment at the top, while
// its shadow grows and darkens as it falls and shrinks and fades as it
// rises. Sized with font-size, coloured with color.
// It only bounces while it's on screen.
// Usage: <div data-bouncing-ball aria-label="Loading"></div>
// Optional: data-speed (ms for one bounce, default 900), data-height (how
// high it bounces, in ball widths, default 2), data-squash (how flat it
// gets on landing, 0 to 0.6, default 0.3), data-colors (comma-separated:
// the ball takes the next colour on every bounce).
var DEFAULTS = { speed: 900, height: 2, squash: 0.3 };
var BALL = 0.8; // em
var css =
'.bba{position:relative;display:inline-block;width:' + BALL * 1.8 + 'em;vertical-align:middle}' +
'.bba-ball{position:absolute;left:50%;bottom:0.2em;width:' + BALL + 'em;height:' + BALL + 'em;margin-left:' + -BALL / 2 + 'em;border-radius:50%;background:currentColor;transform-origin:50% 100%;transition:background-color .2s}' +
'.bba-shadow{position:absolute;left:50%;bottom:0;width:' + BALL * 1.2 + 'em;height:0.2em;margin-left:' + -BALL * 0.6 + 'em;border-radius:50%;background:currentColor;opacity:.3;filter:blur(1px)}';
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 h = Math.max(0.5, Math.min(5, +d.height || DEFAULTS.height)) * BALL;
var sq = Math.max(0, Math.min(0.6, d.squash != null && !isNaN(+d.squash) ? +d.squash : DEFAULTS.squash));
var colors = d.colors ? d.colors.split(',').map(function (c) { return c.trim(); }) : [];
el.classList.add('bba');
if (!el.getAttribute('role')) el.setAttribute('role', 'progressbar');
if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Loading');
el.textContent = '';
el.style.height = (h + BALL + 0.3) + 'em';
var shadow = document.createElement('span');
shadow.className = 'bba-shadow';
var ball = document.createElement('span');
ball.className = 'bba-ball';
shadow.setAttribute('aria-hidden', 'true');
ball.setAttribute('aria-hidden', 'true');
el.appendChild(shadow);
el.appendChild(ball);
var ci = 0;
if (colors.length) ball.style.backgroundColor = colors[0];
if (reduced()) return null;
anime.utils.set(ball, { y: -h + 'em' });
anime.utils.set(shadow, { scale: 0.45, opacity: 0.1 });
var period = Math.max(300, +d.speed || DEFAULTS.speed);
var hit = period * 0.14; // time spent squashing on the floor
var fall = (period - hit) / 2; // time to fall (and to rise)
var tl = anime.createTimeline({
loop: true,
autoplay: false,
onLoop: function () {
if (colors.length > 1) ball.style.backgroundColor = colors[++ci % colors.length];
},
});
tl
// Fall, stretching slightly as it speeds up.
.add(ball, { y: { from: -h + 'em', to: '0em' }, duration: fall, ease: 'in(2.2)' }, 0)
.add(ball, { scaleX: { from: 1, to: 1 - sq * 0.3 }, scaleY: { from: 1, to: 1 + sq * 0.4 }, duration: fall, ease: 'in(3)' }, 0)
// Squash flat, then spring back up stretched.
.add(ball, { scaleX: 1 + sq, scaleY: 1 - sq, duration: hit / 2, ease: 'out(2)' }, fall)
.add(ball, { scaleX: 1 - sq * 0.3, scaleY: 1 + sq * 0.4, duration: hit / 2, ease: 'in(2)' }, fall + hit / 2)
.add(ball, { y: -h + 'em', duration: fall, ease: 'out(2.2)' }, fall + hit)
.add(ball, { scaleX: 1, scaleY: 1, duration: fall * 0.7, ease: 'out(2)' }, fall + hit)
// The shadow tracks the height.
.add(shadow, { scale: { from: 0.45, to: 1 }, opacity: { from: 0.1, to: 0.35 }, duration: fall, ease: 'in(2.2)' }, 0)
.add(shadow, { scale: 1 + sq * 0.5, duration: hit / 2, ease: 'out(2)' }, fall)
.add(shadow, { scale: 1, duration: hit / 2, ease: 'in(2)' }, fall + hit / 2)
.add(shadow, { scale: 0.45, opacity: 0.1, duration: fall, ease: 'out(2.2)' }, fall + hit);
return tl;
}
function init() {
document.querySelectorAll('[data-bouncing-ball]').forEach(function (el) {
if (el._bba) return;
el._bba = 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-bouncing-ball style="font-size: 24px; color: #6a0dad" aria-label="Loading"></div>