← Text effects

Rubber Band

The text stretches wide and squashes flat, then snaps back and forth a few times before it settles, like a pulled elastic band. Play it on scroll, on a loop, or on hover.

Snap back

How to use it

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

Options

Add any of these attributes to the same element.

data-triggerview (default) stretches when scrolled into view. hover stretches when the pointer arrives.
data-loopStretch again every few seconds while on screen (with the view trigger).
data-intervalTime between stretches, when looping. Default 2400 ms.
data-strengthHow far it stretches. Default 1; try 0.5 for something subtler.
data-durationTime for the whole stretch and settle. Default 1000 ms.

Buttons, links and badges stretch as a whole. On a full-width heading only the text stretches, around its own centre.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Rubber Band: the text stretches wide and squashes flat, then snaps back
    // and forth a few times before it settles, like a pulled elastic band.
    // Usage: <h1 data-rubber-band>Boing</h1>
    // Optional: data-trigger ("view", the default, plays when scrolled into
    // view; "hover" plays when the pointer arrives), data-loop (keep stretching
    // every few seconds, with the view trigger), data-interval (ms between
    // stretches when looping; default 2400), data-strength (how far it
    // stretches; default 1), data-duration (ms; default 1000).
    var DEFAULTS = { interval: 2400, strength: 1, duration: 1000 };
    // [scaleX, scaleY, share of the duration] for each snap: wide and flat,
    // tall and thin, and back again, a little less each time.
    var SNAPS = [[1.25, 0.75, 0.3], [0.75, 1.25, 0.1], [1.15, 0.85, 0.1], [0.95, 1.05, 0.15], [1.05, 0.95, 0.1], [1, 1, 0.25]];

    var css = '.rbb-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('rbb-box'); return el; }
      var box = document.createElement('span');
      box.className = 'rbb-box';
      box.style.display = 'inline-block';
      while (el.firstChild) box.appendChild(el.firstChild);
      el.appendChild(box);
      return box;
    }

    function stretch(el, box) {
      var d = el.dataset;
      var s = +d.strength || DEFAULTS.strength;
      var duration = +d.duration || DEFAULTS.duration;
      function keys(axis) {
        return SNAPS.map(function (k) {
          return { to: 1 + (k[axis] - 1) * s, duration: duration * k[2], ease: 'inOut(2)' };
        });
      }
      return anime.animate(box, { scaleX: keys(0), scaleY: keys(1) });
    }

    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._rbbVisible = e.isIntersecting;
          if (e.isIntersecting && !el._rbbPlayed) { el._rbbPlayed = true; el._rbbPlay(); }
        });
      }, { threshold: 0.5 });
      document.querySelectorAll('[data-rubber-band]').forEach(function (el) {
        if (el._rbbPlay) return;
        var d = el.dataset;
        var box = build(el);
        var busy = false;
        el._rbbPlay = function () {
          if (busy) return;
          busy = true;
          stretch(el, box).then(function () { busy = false; });
        };
        if (d.trigger === 'hover') {
          el.addEventListener('pointerenter', el._rbbPlay);
          return;
        }
        io.observe(el);
        if (d.loop !== undefined && d.loop !== 'false') {
          // Only while on screen.
          setInterval(function () { if (el._rbbVisible) el._rbbPlay(); }, +d.interval || DEFAULTS.interval);
        }
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h1 data-rubber-band>Snap back</h1>

<!-- a button that stretches on hover: -->
<button data-rubber-band data-trigger="hover">Play</button>