← Hover & cursor

Pointer Parallax

The layers of a scene shift with the pointer, near ones further than far ones, so a flat picture opens up into depth. Background layers can drift the other way, and when the pointer leaves everything floats back to the middle.

Depth

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-pointer-parallax to the scene.
  3. Give each layer inside a data-depth: 0 stays put, 1 moves the full range, and negative values move the other way.

Options

Add any of these attributes to the same element.

data-rangeHow far a depth-1 layer moves at the edges, in px. Default 40.
data-invertLayers move away from the pointer instead of towards it.
data-autoplayA pretend pointer circles slowly while a real one isn’t over the scene. Handy for demos and touch screens.

Give the scene overflow: hidden and the background layers some extra size, so their edges never come into view.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Pointer Parallax: the layers of a scene shift as the pointer moves over
    // it, near ones more than far ones, so a flat picture opens up into depth.
    // When the pointer leaves, everything floats back to the middle.
    // Usage: <div data-pointer-parallax>
    //          <img data-depth="0.2" src="sky.png">
    //          <img data-depth="0.6" src="hills.png">
    //          <h2 data-depth="1">Foreground</h2>
    //        </div>
    // data-depth on each layer sets how far it moves: 0 stays put, 1 moves the
    // full range, and negative values move the other way (for a background
    // that drifts against the rest).
    // Optional (on the container): data-range (px a depth-1 layer moves at the
    // edges, default 40), data-invert (layers move away from the pointer
    // instead of towards it), data-autoplay (a pretend pointer circles slowly
    // while no real one is over it; handy for demos and touch screens).
    var DEFAULTS = { range: 40 };

    function setup(el) {
      var d = el.dataset;
      var range = +d.range || DEFAULTS.range;
      var sign = d.invert !== undefined && d.invert !== 'false' ? -1 : 1;
      var layers = Array.prototype.slice.call(el.querySelectorAll('[data-depth]')).map(function (n) {
        n.style.willChange = 'transform';
        return { depth: parseFloat(n.dataset.depth) || 0, anim: anime.createAnimatable(n, { x: 900, y: 900, ease: 'out(3)' }) };
      });
      if (!layers.length) return;

      // Shift every layer for a pointer at (u, v), each -1 … 1 from the centre.
      function shift(u, v, duration, ease) {
        layers.forEach(function (l) {
          l.anim.x(u * l.depth * range * sign, duration, ease);
          l.anim.y(v * l.depth * range * sign, duration, ease);
        });
      }
      function at(cx, cy) {
        var r = el.getBoundingClientRect();
        shift(Math.max(-1, Math.min(1, ((cx - r.left) / r.width) * 2 - 1)), Math.max(-1, Math.min(1, ((cy - r.top) / r.height) * 2 - 1)), 900, 'out(3)');
      }

      var real = false;
      el.addEventListener('pointermove', function (e) {
        if (e.pointerType === 'touch') return;
        real = true;
        at(e.clientX, e.clientY);
      });
      el.addEventListener('pointerleave', function (e) {
        if (e.pointerType === 'touch') return;
        real = false;
        if (!timer) shift(0, 0, 1600, 'outElastic(1, .6)');
      });

      // A pretend pointer drifting round in a slow, lopsided loop.
      var timer = null;
      if (d.autoplay !== undefined && d.autoplay !== 'false') {
        var t = 0;
        timer = anime.createTimer({
          loop: true,
          duration: 1000,
          autoplay: false,
          onUpdate: function () {
            if (real) return;
            t += 0.012;
            shift(0.8 * Math.sin(t), 0.6 * Math.sin(t * 1.7 + 0.6), 600, 'out(2)');
          }
        });
        new IntersectionObserver(function (entries) {
          if (entries[0].isIntersecting) timer.play(); else timer.pause();
        }).observe(el);
      }
    }

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

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div class="hero" data-pointer-parallax>
  <img data-depth="-0.3" src="sky.png" alt="">
  <img data-depth="0.4" src="hills.png" alt="">
  <h1 data-depth="0.8">Hello</h1>
</div>