← Hover & cursor

Liquid Distortion Hover

Move over a picture and it ripples and warps under the pointer as if you were looking at it through moving water. Move faster and the warp swells; stop and it settles to a gentle shimmer, then smooths away when you leave.

Under the pointer
data-full

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-liquid-distortion-hover to the frame around a picture. The picture should be its first child and fill it.

Options

Add any of these attributes to the same element.

data-strengthHow far the warp pushes the picture about, in px. Default 26.
data-radiusSize of the rippling patch round the pointer, in px. Default 110.
data-fullWarp the whole picture instead of a patch round the pointer.
data-scaleSize of the ripples: higher makes them finer. Default 1.
data-autoplayA pretend pointer drifts across while a real one isn’t over it. Handy for demos and touch screens.

It uses an SVG filter on a copy of the picture laid over it, so your image is left alone. On touch screens the ripple follows your finger for a moment. With reduced motion turned on, the picture stays still.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Liquid Distortion Hover: the picture ripples and warps under the pointer
    // as if seen through moving water. The warp swells as the pointer moves
    // faster and settles to a gentle shimmer when it stops.
    // Usage: <figure data-liquid-distortion-hover><img src="…" alt="…"></figure>
    // The picture should be the frame's first child and fill it.
    // Optional: data-strength (how far pixels are pushed, px, default 26),
    // data-radius (size of the rippling patch, px, default 110), data-full (warp
    // the whole picture instead of a patch round the pointer), data-scale (size
    // of the ripples: higher is finer, default 1), data-autoplay (a pretend
    // pointer drifts across while a real one isn't over it; handy for demos and
    // touch screens).
    var DEFAULTS = { strength: 26, radius: 110, scale: 1 };
    var NS = 'http://www.w3.org/2000/svg';

    var css =
      '.ldh{position:relative;overflow:hidden;isolation:isolate}' +
      '.ldh-fx{position:absolute;inset:0;z-index:1;pointer-events:none;visibility:hidden}' +
      '.ldh-fx>*{display:block;width:100%;height:100%;max-width:none;margin:0}' +
      '.ldh-defs{position:absolute;width:0;height:0;overflow:hidden}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function num(v, def) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : def; }

    var uid = 0;

    function setup(el) {
      var d = el.dataset;
      var strength = num(d.strength, DEFAULTS.strength);
      var radius = num(d.radius, DEFAULTS.radius);
      var fine = num(d.scale, DEFAULTS.scale);
      var full = d.full !== undefined && d.full !== 'false';
      var pic = el.firstElementChild;
      if (!pic) return;
      el.classList.add('ldh');

      // The warped copy sits over the picture, masked to a soft circle round
      // the pointer, so the picture itself is never touched.
      var fx = document.createElement('div');
      fx.className = 'ldh-fx';
      fx.setAttribute('aria-hidden', 'true');
      var copy = pic.cloneNode(true);
      copy.removeAttribute('id');
      if (copy.tagName === 'IMG') copy.style.objectFit = getComputedStyle(pic).objectFit === 'fill' ? 'cover' : getComputedStyle(pic).objectFit;
      fx.appendChild(copy);

      var id = 'ldh-f' + (++uid);
      var svg = document.createElementNS(NS, 'svg');
      svg.setAttribute('class', 'ldh-defs');
      svg.setAttribute('aria-hidden', 'true');
      svg.innerHTML =
        '<filter id="' + id + '" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">' +
        '<feTurbulence type="fractalNoise" baseFrequency="0.012 0.02" numOctaves="2" seed="' + (uid * 7 % 97) + '" result="n"/>' +
        '<feDisplacementMap in="SourceGraphic" in2="n" scale="0" xChannelSelector="R" yChannelSelector="G"/>' +
        '</filter>';
      el.appendChild(svg);
      el.appendChild(fx);
      var filter = svg.firstChild, turb = filter.firstChild, disp = turb.nextSibling;
      fx.style.filter = 'url(#' + id + ')';

      var s = { x: 0, y: 0, tx: 0, ty: 0, k: 0, boost: 0, t: Math.random() * 10 };
      var hover = false, real = false, hold = 0, visible = false, raf = 0, w = 0, h = 0;
      var auto = d.autoplay !== undefined && d.autoplay !== 'false';

      function size() { w = el.clientWidth; h = el.clientHeight; }
      size();
      new ResizeObserver(size).observe(el);

      function aim(cx, cy) {
        var r = el.getBoundingClientRect();
        var x = cx - r.left, y = cy - r.top;
        if (!hover && s.k < 0.05) { s.x = x; s.y = y; }
        s.tx = x; s.ty = y;
      }

      function frame() {
        raf = 0;
        if (!visible) return;
        var touchHeld = Date.now() < hold;
        // Pretend pointer: a slow loop across the picture.
        if (auto && !real && !touchHeld) {
          s.tx = w * (0.5 + 0.34 * Math.sin(s.t * 0.55));
          s.ty = h * (0.5 + 0.3 * Math.sin(s.t * 0.9 + 1));
          if (s.k < 0.05) { s.x = s.tx; s.y = s.ty; }
        }
        var on = hover || touchHeld || (auto && !real);
        var px = s.x, py = s.y;
        s.x += (s.tx - s.x) * 0.16;
        s.y += (s.ty - s.y) * 0.16;
        var speed = Math.hypot(s.x - px, s.y - py);
        s.boost += (Math.min(1, speed / 18) - s.boost) * 0.08;
        s.k += ((on ? 1 : 0) - s.k) * (on ? 0.07 : 0.05);
        s.t += 0.016;

        var f = 0.011 * fine;
        turb.setAttribute('baseFrequency', (f + 0.003 * Math.sin(s.t * 1.3)).toFixed(5) + ' ' + (f * 1.7 + 0.004 * Math.cos(s.t)).toFixed(5));
        disp.setAttribute('scale', (strength * s.k * (0.55 + 0.9 * s.boost)).toFixed(2));

        if (full) {
          filter.setAttribute('x', 0); filter.setAttribute('y', 0);
          filter.setAttribute('width', w); filter.setAttribute('height', h);
        } else {
          // Only work out the patch round the pointer: much cheaper.
          var r = radius * (0.85 + 0.35 * s.boost);
          filter.setAttribute('x', (s.x - r - 10).toFixed(1)); filter.setAttribute('y', (s.y - r - 10).toFixed(1));
          filter.setAttribute('width', (r * 2 + 20).toFixed(1)); filter.setAttribute('height', (r * 2 + 20).toFixed(1));
          var m = 'radial-gradient(circle at ' + s.x.toFixed(1) + 'px ' + s.y.toFixed(1) + 'px,#000 ' + (r * 0.35).toFixed(1) + 'px,transparent ' + r.toFixed(1) + 'px)';
          fx.style.webkitMaskImage = fx.style.maskImage = m;
        }
        fx.style.visibility = s.k > 0.01 ? 'visible' : 'hidden';
        if (on || s.k > 0.01) raf = requestAnimationFrame(frame);
      }
      function wake() { if (!raf && visible) raf = requestAnimationFrame(frame); }

      el.addEventListener('pointerenter', function (e) {
        if (e.pointerType === 'touch') return;
        real = hover = true;
        aim(e.clientX, e.clientY);
        wake();
      });
      el.addEventListener('pointermove', function (e) {
        if (e.pointerType === 'touch') hold = Date.now() + 3000;
        else real = hover = true;
        aim(e.clientX, e.clientY);
        wake();
      });
      el.addEventListener('pointerleave', function (e) {
        if (e.pointerType === 'touch') return;
        real = hover = false;
        wake();
      });
      // Touch: the ripple jumps to the finger and lingers a moment.
      el.addEventListener('pointerdown', function (e) {
        if (e.pointerType !== 'touch') return;
        hold = Date.now() + 3000;
        s.k = Math.min(s.k, 0.04);
        aim(e.clientX, e.clientY);
        s.boost = 1;
        wake();
      });

      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (visible) wake();
      }).observe(el);
    }

    function init() {
      if (reduced()) return;
      document.querySelectorAll('[data-liquid-distortion-hover]').forEach(function (el) {
        if (el._ldh) return;
        el._ldh = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<figure data-liquid-distortion-hover>
  <img src="lake.jpg" alt="A mountain lake">
</figure>

<!-- the whole picture, a little calmer: -->
<figure data-liquid-distortion-hover data-full data-strength="18">
  <img src="aurora.jpg" alt="Northern lights">
</figure>