← Text effects

Jello Wobble

A quick squash, then a skewing wobble that dies away, each sway half the last, like a jelly that has just been nudged. Ideal for pointing the eye at a button.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-jello-wobble to a button, link or heading.

Options

Add any of these attributes to the same element.

data-triggerview (default) wobbles when scrolled into view. hover wobbles when the pointer arrives.
data-loopWobble again every few seconds while on screen (with the view trigger).
data-intervalTime between wobbles, when looping. Default 2600 ms.
data-angleThe first, biggest skew, in degrees. Default 12.
data-durationTime for the whole wobble. Default 1000 ms.

Use it sparingly: one wobbling call to action on a page is plenty.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Jello Wobble: the text skews one way, then the other, each wobble smaller
    // than the last, like a jelly that has just been nudged. Good for drawing
    // the eye to a button or a badge.
    // Usage: <a class="btn" href="/signup" data-jello-wobble data-loop>Sign up</a>
    // Optional: data-trigger ("view", the default, wobbles when scrolled into
    // view; "hover" wobbles when the pointer arrives), data-loop (wobble every
    // few seconds, with the view trigger), data-interval (ms between wobbles
    // when looping; default 2600), data-angle (the first, biggest skew in
    // degrees; default 12), data-duration (ms; default 1000).
    var DEFAULTS = { interval: 2600, angle: 12, duration: 1000 };
    var WOBBLES = 8; // each one half the size of the last, alternating sides

    var css = '.jlw-box{will-change:transform}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    // What moves: buttons, badges and other boxes move as a whole. A block
    // heading's text goes in an inline box, so it moves around its own centre
    // rather than the middle of the full-width block.
    function build(el) {
      var display = getComputedStyle(el).display;
      if (display === 'inline') el.style.display = 'inline-block';
      if (display !== 'block') { el.classList.add('jlw-box'); return el; }
      var box = document.createElement('span');
      box.className = 'jlw-box';
      box.style.display = 'inline-block';
      while (el.firstChild) box.appendChild(el.firstChild);
      el.appendChild(box);
      return box;
    }

    function wobble(el, box) {
      var d = el.dataset;
      var angle = +d.angle || DEFAULTS.angle;
      var step = (+d.duration || DEFAULTS.duration) / (WOBBLES + 1);
      function keys() {
        var k = [];
        for (var i = 0; i < WOBBLES; i++) {
          k.push({ to: (i % 2 ? 1 : -1) * angle / Math.pow(2, i), duration: step, ease: 'inOutSine' });
        }
        k.push({ to: 0, duration: step, ease: 'outSine' });
        return k;
      }
      return anime.animate(box, {
        skewX: keys(),
        skewY: keys(),
        // A quick squash as it's hit, recovering over the first wobbles.
        scaleY: [{ to: 0.92, duration: step, ease: 'out(2)' }, { to: 1, duration: step * 3, ease: 'outElastic(1, .5)' }]
      });
    }

    function init() {
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var el = e.target;
          el._jlwVisible = e.isIntersecting;
          if (e.isIntersecting && !el._jlwPlayed) { el._jlwPlayed = true; el._jlwPlay(); }
        });
      }, { threshold: 0.5 });
      document.querySelectorAll('[data-jello-wobble]').forEach(function (el) {
        if (el._jlwPlay) return;
        var d = el.dataset;
        var box = build(el);
        var busy = false;
        el._jlwPlay = function () {
          if (busy) return;
          busy = true;
          wobble(el, box).then(function () { busy = false; });
        };
        if (d.trigger === 'hover') {
          el.addEventListener('pointerenter', el._jlwPlay);
          return;
        }
        io.observe(el);
        if (d.loop !== undefined && d.loop !== 'false') {
          // Only while on screen.
          setInterval(function () { if (el._jlwVisible) el._jlwPlay(); }, +d.interval || DEFAULTS.interval);
        }
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<a class="btn" href="/signup" data-jello-wobble data-loop>Claim your spot</a>

<!-- wobble on hover instead: -->
<a class="btn" href="/signup" data-jello-wobble data-trigger="hover">Claim your spot</a>