← Text effects

Letter Hover Lift

Letters near the pointer rise up and lean apart, as if a magnifier were passing under the line, and spring back down when it leaves. Move along the word and the bump follows.

Hover over me

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-letter-hover-lift to a heading or link.

Options

Add any of these attributes to the same element.

data-liftHow high the nearest letter rises, in em. Default 0.35.
data-spreadHow far letters lean apart, in em. Default 0.12.
data-radiusHow far the effect reaches either side of the pointer, in em. Default 2.
data-colorA colour the lifted letters take.
data-autoplayA pretend pointer sweeps along the text whenever a real one isn’t over it. Handy for demos and touch screens.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Letter Hover Lift: letters near the pointer rise up and lean apart, as if
    // the pointer were a magnifier passing under the line. They spring back
    // down when it leaves.
    // Usage: <h1 data-letter-hover-lift>Hover over me</h1>
    // Optional: data-lift (how high the nearest letter rises, in em; default
    // 0.35), data-spread (how far letters lean apart, in em; default 0.12),
    // data-radius (how far the effect reaches either side of the pointer, in
    // em; default 2), data-color (colour the lifted letters take), data-autoplay
    // (a pretend pointer sweeps along the text while no real one is over it;
    // handy for demos and touch screens).
    var DEFAULTS = { lift: 0.35, spread: 0.12, radius: 2 };

    var css =
      '.lhl-word{display:inline-block;white-space:nowrap}' +
      '.lhl-char{display:inline-block;transform-origin:50% 100%;transition:color .35s;will-change:transform}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    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 = 'lhl-word';
        word.setAttribute('aria-hidden', 'true');
        Array.from(w).forEach(function (c) {
          var s = document.createElement('span');
          s.className = 'lhl-char';
          s.textContent = c;
          word.appendChild(s);
          chars.push({ el: s, anim: anime.createAnimatable(s, { x: 350, y: 350, scale: 350, ease: 'out(3)' }) });
        });
        el.appendChild(word);
      });
      return chars;
    }

    // Each letter's centre on screen, worked out from layout (offsets ignore
    // transforms), so letters that are already lifted don't move the target.
    function measure(el, chars) {
      var r = el.getBoundingClientRect();
      chars.forEach(function (c) {
        var s = c.el;
        c.cx = r.left + el.clientLeft + s.offsetLeft + s.offsetWidth / 2;
        c.cy = r.top + el.clientTop + s.offsetTop + s.offsetHeight / 2;
      });
    }

    function setup(el) {
      var d = el.dataset;
      if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
      var chars = split(el);
      var fs = parseFloat(getComputedStyle(el).fontSize);
      var lift = fs * (d.lift !== undefined ? +d.lift : DEFAULTS.lift);
      var spread = fs * (d.spread !== undefined ? +d.spread : DEFAULTS.spread);
      var radius = fs * (+d.radius || DEFAULTS.radius);
      var base = getComputedStyle(el).color;

      // Lift every letter for a pointer at (px, py), in screen coordinates.
      function update(px, py) {
        chars.forEach(function (c) {
          var dx = c.cx - px;
          var dy = (c.cy - py) * 0.6;
          var dist = Math.sqrt(dx * dx + dy * dy);
          // A smooth bump: 1 right under the pointer, 0 at the radius.
          var f = dist < radius ? (1 + Math.cos(Math.PI * dist / radius)) / 2 : 0;
          // Duration and ease every time: an animatable keeps the last ones it
          // was given, and rest() springs back with slower ones.
          c.anim.y(-lift * f, 350, 'out(3)');
          c.anim.x((dx / radius) * spread * 2 * f, 350, 'out(3)');
          c.anim.scale(1 + 0.14 * f, 350, 'out(3)');
          if (d.color) c.el.style.color = f > 0.02 ? 'color-mix(in srgb, ' + d.color + ' ' + Math.round(f * 100) + '%, ' + base + ')' : '';
        });
      }

      function rest() {
        chars.forEach(function (c) {
          c.anim.y(0, 900, 'outElastic(1, .5)');
          c.anim.x(0, 900, 'outElastic(1, .5)');
          c.anim.scale(1, 900, 'outElastic(1, .5)');
          c.el.style.color = '';
        });
      }

      var hovering = false;
      el.addEventListener('pointerenter', function () { hovering = true; measure(el, chars); if (sweep) sweep.pause(); });
      el.addEventListener('pointermove', function (e) { measure(el, chars); update(e.clientX, e.clientY); });
      el.addEventListener('pointerleave', function () {
        hovering = false;
        rest();
        if (sweep && visible) setTimeout(function () { if (!hovering && visible) sweep.play(); }, 1200);
      });

      // A pretend pointer that glides from one end of the text to the other and back.
      var sweep = null;
      var visible = false;
      if (d.autoplay !== undefined && d.autoplay !== 'false') {
        var pos = { p: 0 };
        sweep = anime.animate(pos, {
          p: 1,
          duration: Math.max(1600, chars.length * 160),
          ease: 'inOutSine',
          loop: true,
          alternate: true,
          autoplay: false,
          onUpdate: function () {
            measure(el, chars);
            var r = el.getBoundingClientRect();
            // Run off both ends, so the letters at each end settle too.
            update(r.left - radius * 0.6 + (r.width + radius * 1.2) * pos.p, chars[0].cy);
          }
        });
        new IntersectionObserver(function (entries) {
          visible = entries[0].isIntersecting;
          if (visible && !hovering) sweep.play(); else sweep.pause();
        }).observe(el);
      }
    }

    function init() {
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
      document.querySelectorAll('[data-letter-hover-lift]').forEach(function (el) {
        if (el._lhl) return;
        el._lhl = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h1 data-letter-hover-lift>Hover over me</h1>

<!-- turns purple as it lifts: -->
<h1 data-letter-hover-lift data-color="#a855f7">Hover over me</h1>