← Text effects

Inflating Letters

Hover over a letter and it puffs up like a balloon, rising a little, catching a glossy highlight and nudging its neighbours to swell too. Then it slowly lets the air out and settles back into the line.

Pump it up

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-inflating-letters to a heading. Leave a little room above it, as the letters grow upwards.

Options

Add any of these attributes to the same element.

data-sizeHow much a letter grows: 0.6 is 60% bigger. Default 0.55.
data-spreadHow much the letters either side swell, from 0 to 1. Default 0.4.
data-deflateHow long a letter takes to go back down. Default 2600 ms.
data-autoplayLetters inflate by themselves every so often while nobody is hovering or touching. Handy for demos and touch screens.

On a touch screen, tap a letter or slide a finger along the line to blow them up.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Inflating Letters: hover over a letter and it puffs up like a balloon,
    // rising a little and catching a glossy highlight, with its neighbours
    // swelling a bit too. Then it slowly deflates back to normal.
    // Usage: <h1 data-inflating-letters>Pump it up</h1>
    // Optional: data-size (how much a letter grows, 0.6 = 60% bigger; default
    // 0.55), data-spread (how much its neighbours swell, 0 to 1; default 0.4),
    // data-deflate (ms it takes to go back down; default 2600), data-autoplay
    // (letters inflate by themselves while nobody is hovering or touching).
    var DEFAULTS = { size: 0.55, spread: 0.4, deflate: 2600 };

    var css =
      '.infl-word{display:inline-block;white-space:nowrap}' +
      '.infl-ch{display:inline-block;position:relative;transform-origin:50% 85%;will-change:transform}' +
      '.infl-gl{position:absolute;left:0;top:0;right:0;bottom:0;color:transparent;-webkit-text-fill-color:transparent;' +
      'background:radial-gradient(60% 45% at 38% 32%,rgba(255,255,255,.9),rgba(255,255,255,0) 70%);-webkit-background-clip:text;background-clip:text;opacity:0;text-shadow:none;pointer-events:none}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function split(el) {
      var text = el.textContent.replace(/\s+/g, ' ').trim();
      el.setAttribute('aria-label', text);
      el.textContent = '';
      var chars = [];
      text.split(' ').forEach(function (word, i) {
        if (i) el.appendChild(document.createTextNode(' '));
        var w = document.createElement('span');
        w.className = 'infl-word';
        w.setAttribute('aria-hidden', 'true');
        Array.from(word).forEach(function (c) {
          var s = document.createElement('span');
          s.className = 'infl-ch';
          s.textContent = c;
          var g = document.createElement('span');
          g.className = 'infl-gl';
          g.textContent = c;
          s.appendChild(g);
          w.appendChild(s);
          chars.push({ el: s, gloss: g, s: 0, tilt: 0, anim: null, i: chars.length });
        });
        el.appendChild(w);
      });
      return chars;
    }

    function setup(el) {
      var d = el.dataset;
      var size = d.size !== undefined ? +d.size : DEFAULTS.size;
      var spread = d.spread !== undefined ? +d.spread : DEFAULTS.spread;
      var deflate = +d.deflate || DEFAULTS.deflate;
      var chars = split(el);
      if (reduced() || !chars.length) return;

      function draw(c) {
        var s = c.s;
        c.el.style.transform = 'translateY(' + (-0.1 * s).toFixed(3) + 'em) rotate(' + (c.tilt * s).toFixed(2) + 'deg) scale(' +
          (1 + size * 0.85 * s).toFixed(3) + ',' + (1 + size * s).toFixed(3) + ')';
        c.el.style.textShadow = s > 0.01 ? '0 ' + (0.12 * s).toFixed(3) + 'em ' + (0.14 * s).toFixed(3) + 'em rgba(30,10,60,' + (0.22 * s).toFixed(3) + ')' : '';
        c.el.style.zIndex = s > 0.01 ? Math.round(s * 10) + 1 : '';
        c.gloss.style.opacity = Math.min(1, s * 1.3).toFixed(3);
      }

      function inflate(c, amount) {
        if (amount <= c.s + 0.02) return;
        if (c.anim) c.anim.pause();
        if (c.s < 0.05) c.tilt = anime.utils.random(-7, 7);
        var tl = anime.createTimeline({ onUpdate: function () { draw(c); } });
        tl.add(c, { s: amount, duration: 520, ease: 'outElastic(1, .55)' })
          .add(c, { s: amount * 0.9, duration: 500, ease: 'inOut(2)' })
          // Slowly lose air, with a last little wobble as it settles.
          .add(c, { s: 0, duration: deflate, ease: 'inOut(3)' })
          .add(c, { tilt: 0, duration: 1 });
        c.anim = tl;
      }

      function poke(c) {
        inflate(c, 1);
        [-1, 1].forEach(function (k) {
          var n = chars[c.i + k];
          if (n && spread > 0) inflate(n, spread);
        });
      }

      var lastReal = -1e9;
      chars.forEach(function (c) {
        c.el.addEventListener('pointerenter', function (e) {
          if (e.pointerType !== 'mouse') return;
          lastReal = performance.now();
          poke(c);
        });
      });
      // Touch: inflate what the finger is on, and what it slides over.
      function touchAt(e) {
        if (e.pointerType === 'mouse' && e.type !== 'pointerdown') return;
        lastReal = performance.now();
        var hit = document.elementFromPoint(e.clientX, e.clientY);
        var ch = hit && hit.closest && hit.closest('.infl-ch');
        chars.forEach(function (c) { if (c.el === ch) poke(c); });
      }
      el.addEventListener('pointerdown', touchAt);
      el.addEventListener('pointermove', function (e) { if (e.pointerType !== 'mouse' && e.buttons) touchAt(e); });

      if (d.autoplay === undefined || d.autoplay === 'false') return;
      var letters = chars.filter(function (c) { return /\S/.test(c.el.firstChild.nodeValue); });
      var timer = null;
      function tick() {
        if (performance.now() - lastReal > 2500) {
          // Mostly a random letter, now and then a run along the word.
          if (Math.random() < 0.25) {
            letters.forEach(function (c, i) { setTimeout(function () { inflate(c, 0.6); }, i * 70); });
          } else {
            poke(letters[Math.floor(Math.random() * letters.length)]);
          }
        }
        timer = setTimeout(tick, anime.utils.random(700, 1100));
      }
      new IntersectionObserver(function (entries) {
        clearTimeout(timer);
        if (entries[0].isIntersecting) timer = setTimeout(tick, 300);
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-inflating-letters]').forEach(function (el) {
        if (el._infl) return;
        el._infl = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h1 data-inflating-letters>Pump it up</h1>

<!-- bigger balloons that go down faster: -->
<h1 data-inflating-letters data-size="0.8" data-deflate="1600">Pump it up</h1>