← Loaders

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

  1. Paste the snippet just before </head>.
  2. Add an empty <div data-bouncing-ball> where the loader should appear.

Options

Add any of these attributes to the same element.

data-speedTime for one bounce. Default 900 ms.
data-heightHow high it bounces, in ball widths. Default 2.
data-squashHow flat it gets on landing, 0 to 0.6. Default 0.3.
data-colorsComma-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.

1 · Snippet
<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>
2 · Your HTML
<div data-bouncing-ball style="font-size: 24px; color: #6a0dad" aria-label="Loading"></div>