← Text effects

Stretchy Headline

Grab the headline anywhere and pull. The letters under your finger follow it while their neighbours stretch and lean to keep up, like a rubber band, and the further you pull the harder it resists. Let go and it snaps back with a wobble.

Pull me about

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-stretchy-headline to a heading.

Options

Add any of these attributes to the same element.

data-reachHow far along the line the pull spreads, in em. Default 2.
data-maxHow far it can stretch before it stops giving, in em. Default 1.6.
data-autoplayA pretend hand gives it a tug every few seconds while nobody is touching it.
data-intervalTime between autoplay tugs. Default 2800 ms.

Works with a mouse or a finger. While a finger is on the headline it stretches instead of scrolling the page, so keep it to a heading rather than a long paragraph.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Stretchy Headline: grab the headline anywhere and pull. The letters near
    // your finger follow it and the ones either side stretch and lean to keep
    // up, like a rubber band; let go and it snaps back with a wobble.
    // Usage: <h1 data-stretchy-headline>Pull me</h1>
    // Optional: data-reach (how far along the line the pull spreads, in em;
    // default 2), data-max (the furthest it stretches, in em; default 1.6),
    // data-autoplay (a pretend hand gives it a tug every few seconds while
    // nobody is touching it), data-interval (ms between autoplay tugs;
    // default 2800).
    var DEFAULTS = { reach: 2, max: 1.6, interval: 2800 };

    var css =
      '.sty-box{cursor:grab;touch-action:none;user-select:none;-webkit-user-select:none}' +
      '.sty-box.sty-held{cursor:grabbing}' +
      '.sty-word{display:inline-block;white-space:nowrap}' +
      '.sty-ch{display:inline-block;will-change:transform}';
    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 (w, i) {
        if (i) el.appendChild(document.createTextNode(' '));
        var word = document.createElement('span');
        word.className = 'sty-word';
        word.setAttribute('aria-hidden', 'true');
        Array.from(w).forEach(function (c) {
          var s = document.createElement('span');
          s.className = 'sty-ch';
          s.textContent = c;
          word.appendChild(s);
          chars.push({ el: s });
        });
        el.appendChild(word);
      });
      return chars;
    }

    function setup(el) {
      var d = el.dataset;
      var reach = +d.reach || DEFAULTS.reach;
      var maxPull = +d.max || DEFAULTS.max;
      var interval = +d.interval || DEFAULTS.interval;
      var autoplay = d.autoplay !== undefined && d.autoplay !== 'false';
      if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
      var chars = split(el);
      if (reduced() || !chars.length) return;
      el.classList.add('sty-box');

      var fs = 16;
      function measure() {
        fs = parseFloat(getComputedStyle(el).fontSize);
        chars.forEach(function (c) {
          c.cx = c.el.offsetLeft + c.el.offsetWidth / 2;
          c.cy = c.el.offsetTop + c.el.offsetHeight / 2;
        });
      }
      measure();
      new ResizeObserver(measure).observe(el);

      // The grab: where it started and how far it has been pulled.
      var pull = { gx: 0, gy: 0, dx: 0, dy: 0 };

      // Soft limit: the further you pull, the harder it resists.
      function soft(v, m) { return m * Math.tanh(v / m); }

      function render() {
        var sigma = reach * fs, m = maxPull * fs;
        var dx = soft(pull.dx, m), dy = soft(pull.dy, m);
        chars.forEach(function (c) {
          var ox = c.cx - pull.gx, oy = (c.cy - pull.gy) * 0.6;
          var dist2 = ox * ox + oy * oy;
          var w = Math.exp(-dist2 / (2 * sigma * sigma));
          // Slope of the bump along the line: how much this letter is stretched.
          var dw = -ox / (sigma * sigma) * w;
          var tx = dx * w, ty = dy * w;
          var sx = Math.max(0.6, 1 + dx * dw);
          var lean = Math.atan(dy * dw) * 0.9;
          var sy = 1 + Math.abs(dy) / fs * w * 0.12;
          c.el.style.transform = 'translate(' + tx.toFixed(1) + 'px,' + ty.toFixed(1) + 'px) rotate(' + lean.toFixed(3) + 'rad) scale(' + sx.toFixed(3) + ',' + sy.toFixed(3) + ')';
        });
      }

      var spring = null;
      function snap() {
        if (spring) spring.pause();
        spring = anime.animate(pull, { dx: 0, dy: 0, duration: 1300, ease: 'outElastic(1, .35)', onUpdate: render });
      }

      function local(e) {
        var r = el.getBoundingClientRect();
        return { x: e.clientX - r.left - el.clientLeft, y: e.clientY - r.top - el.clientTop };
      }

      var held = false, sx0 = 0, sy0 = 0, lastReal = -1e9;
      el.addEventListener('pointerdown', function (e) {
        e.preventDefault();
        held = true; lastReal = performance.now();
        if (spring) spring.pause();
        if (fake) { fake.pause(); fake = null; }
        var p = local(e);
        // Grabbing a still-wobbling line carries on from where it is.
        pull.gx = p.x; pull.gy = p.y; sx0 = p.x - pull.dx; sy0 = p.y - pull.dy;
        el.classList.add('sty-held');
        try { el.setPointerCapture(e.pointerId); } catch (err) { /* ignore */ }
      });
      el.addEventListener('pointermove', function (e) {
        if (!held) return;
        lastReal = performance.now();
        var p = local(e);
        pull.dx = p.x - sx0; pull.dy = p.y - sy0;
        render();
      });
      function letGo() {
        if (!held) return;
        held = false; lastReal = performance.now();
        el.classList.remove('sty-held');
        snap();
      }
      el.addEventListener('pointerup', letGo);
      el.addEventListener('pointercancel', letGo);

      if (!autoplay) return;
      var fake = null, timer = null;
      function tug() {
        if (!held && performance.now() - lastReal > 4000 && chars.length) {
          var c = chars[Math.floor(Math.random() * chars.length)];
          if (spring) spring.pause();
          pull.gx = c.cx; pull.gy = c.cy; pull.dx = 0; pull.dy = 0;
          var ang = anime.utils.random(0, 360) * Math.PI / 180;
          if (c.cx < el.clientWidth * 0.3) ang = anime.utils.random(-60, 60) * Math.PI / 180 + Math.PI;
          else if (c.cx > el.clientWidth * 0.7) ang = anime.utils.random(-60, 60) * Math.PI / 180;
          var len = maxPull * fs * anime.utils.random(1.1, 1.8, 2);
          fake = anime.animate(pull, {
            dx: Math.cos(ang) * len, dy: Math.sin(ang) * len * 0.8,
            duration: 650, ease: 'out(3)', onUpdate: render,
            onComplete: function () { fake = null; setTimeout(function () { if (!held) snap(); }, 120); }
          });
        }
        timer = setTimeout(tug, interval);
      }
      new IntersectionObserver(function (entries) {
        clearTimeout(timer);
        if (entries[0].isIntersecting) timer = setTimeout(tug, 600);
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-stretchy-headline]').forEach(function (el) {
        if (el._sty) return;
        el._sty = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h1 data-stretchy-headline>Pull me about</h1>

<!-- a looser band that reaches further: -->
<h1 data-stretchy-headline data-reach="4" data-max="2.4">Pull me about</h1>