← Hover & cursor

Image Zoom Hover

Hover a picture and it slowly scales up inside its frame, then eases back when the pointer leaves. Add panning and it also drifts towards the pointer, so you can look around the picture.

Lakes
Aurora data-pan
Dunes

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-image-zoom-hover to the frame around a picture. The picture should be its first child.

Options

Add any of these attributes to the same element.

data-scaleHow far it zooms. Default 1.1.
data-durationHow long the zoom takes. Default 900 ms.
data-panDrift towards the pointer while zoomed.
data-autoplayZoom in and out on a timer while the pointer isn’t over it. Handy for demos and touch screens.

The frame clips the picture, so give it rounded corners if you like. A tap zooms on touch screens.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Image Zoom Hover: hover a picture and it scales up gently inside its frame,
    // easing back when the pointer leaves. With data-pan it also drifts towards
    // the pointer, so you can look around the picture.
    // Usage: <figure data-image-zoom-hover><img src="photo.jpg" alt="…"></figure>
    // Optional: data-scale (how far it zooms, default 1.1), data-duration (ms,
    // default 900), data-pan (drift towards the pointer while zoomed),
    // data-autoplay (zoom in and out on a timer while the pointer isn't over
    // it; handy for demos and touch screens).
    // The picture is the first child of the element; the element is the frame.
    var DEFAULTS = { scale: 1.1, duration: 900 };

    var css =
      '.izh{overflow:hidden;position:relative}' +
      '.izh-pic{display:block;transform-origin:50% 50%;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 setup(el) {
      var d = el.dataset;
      var scale = d.scale !== undefined ? +d.scale : DEFAULTS.scale;
      var duration = d.duration !== undefined ? +d.duration : DEFAULTS.duration;
      var pan = d.pan !== undefined && d.pan !== 'false';
      var pic = el.firstElementChild;
      if (!pic) return null;
      el.classList.add('izh');
      pic.classList.add('izh-pic');
      var anim = anime.createAnimatable(pic, { x: 700, y: 700, scale: duration, ease: 'out(3)' });
      var on = false;

      // u, v from -1 to 1 across the frame: drift that way, as far as the zoom allows.
      function look(u, v) {
        if (!pan || !on) return;
        var room = (scale - 1) / 2;
        anim.x(-u * room * el.offsetWidth);
        anim.y(-v * room * el.offsetHeight);
      }
      function enter() {
        if (on) return;
        on = true;
        anim.scale(scale, reduced() ? 0 : duration);
      }
      function leave() {
        if (!on) return;
        on = false;
        var ms = reduced() ? 0 : duration;
        anim.scale(1, ms);
        anim.x(0, ms);
        anim.y(0, ms);
      }
      function point(e) {
        var r = el.getBoundingClientRect();
        look(((e.clientX - r.left) / r.width) * 2 - 1, ((e.clientY - r.top) / r.height) * 2 - 1);
      }

      el.addEventListener('pointerenter', function (e) { if (e.pointerType !== 'touch') { el._izhReal = true; enter(); point(e); } });
      el.addEventListener('pointermove', function (e) { if (e.pointerType !== 'touch') point(e); });
      el.addEventListener('pointerleave', function (e) { if (e.pointerType !== 'touch') { el._izhReal = false; leave(); } });
      // Touch: a tap zooms in (towards the tap, with data-pan), another zooms out.
      el.addEventListener('pointerdown', function (e) {
        if (e.pointerType !== 'touch') return;
        el._izhHold = Date.now() + 5000;
        if (on) leave();
        else { enter(); point(e); }
      });
      el.addEventListener('focusin', enter);
      el.addEventListener('focusout', function (e) { if (!el.contains(e.relatedTarget)) leave(); });
      return { enter: enter, leave: leave, look: look, isOn: function () { return on; } };
    }

    function autoplay(el, api, offset) {
      var visible = false, timer = null, mine = false, t = 0;
      function tick() {
        if (!visible) return;
        var busy = el._izhReal || el.contains(document.activeElement) || (el._izhHold && Date.now() < el._izhHold);
        if (busy) { mine = false; timer = setTimeout(tick, 800); return; }
        if (mine) { api.leave(); mine = false; timer = setTimeout(tick, 1400); }
        else if (!api.isOn()) {
          api.enter();
          mine = true;
          // A pretend pointer glances across the picture while it's zoomed.
          t += 1.7;
          api.look(Math.cos(t) * 0.8, Math.sin(t) * 0.6);
          timer = setTimeout(tick, 2000);
        } else timer = setTimeout(tick, 800);
      }
      new IntersectionObserver(function (entries) {
        var was = visible;
        visible = entries[0].isIntersecting;
        if (visible && !was) { clearTimeout(timer); timer = setTimeout(tick, 400 + offset); }
      }).observe(el);
    }

    var count = 0;

    function init() {
      document.querySelectorAll('[data-image-zoom-hover]').forEach(function (el) {
        if (el._izh) return;
        el._izh = true;
        var api = setup(el);
        var auto = el.dataset.autoplay;
        if (!api || auto === undefined || auto === 'false' || reduced()) return;
        // Pictures take turns, so a gallery ripples rather than flashing at once.
        autoplay(el, api, (count++ % 3) * 800);
      });
    }

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

<!-- follows the pointer around: -->
<figure class="frame" data-image-zoom-hover data-pan data-scale="1.2">
  <img src="aurora.jpg" alt="Northern lights">
</figure>