← Diagrams & maps

Detail Zoom Callout

Show off the small things in a picture. A ring snaps around a detail, two lines draw out from it, and a big round lens grows out of the ring to one side, zooming in on the detail as it lands. Give it several details and it closes the lens and moves on to the next.

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap your picture (an <img> or inline SVG) in an element with data-detail-zoom-callout. The picture must be its first child.
  3. List the details in data-points as x,y pairs from 0 to 100, separated by semicolons, such as data-points="25,62;73,83".

Options

Add any of these attributes to the same element.

data-pointsWhere the details are, from the top left, 0–100 across and down. Default 50,50.
data-radiusSize of the small ring, as a percentage of the picture’s width. Default 7.
data-zoomHow much bigger the lens is than the ring. It shrinks to fit the picture. Default 3.
data-colorColour of the ring, lens and lines. Default #7c3aed.
data-holdHow long each detail stays open. Default 2200 ms.
data-loopKeep moving round the details. Without it, it stops on the last one.

The lens sits on the opposite side of the picture from the detail, so give it details away from the middle. A sharp picture (SVG or a large image) looks best close up.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Detail Zoom Callout: a small circle rings a detail on a picture, then a
    // big magnified circle grows out of it to one side, joined to it by two
    // lines, showing the detail up close. With several details it moves from
    // one to the next.
    // Usage:
    //   <div data-detail-zoom-callout data-points="25,62;73,83">
    //     <img src="scene.png" alt="Village by a lake">
    //   </div>
    // The first child is the picture (an <img>, <svg> or anything else). Each
    // point in data-points is "x,y" from 0 to 100 (0, 0 is the top left).
    // Optional: data-points (default 50,50), data-radius (small circle size
    // as a % of the picture's width, default 7), data-zoom (how much bigger
    // the magnified circle is, default 3; it shrinks to fit), data-color
    // (default #7c3aed), data-hold (ms each detail stays open, default 2200),
    // data-loop (keep cycling through the points).
    var DEFAULTS = { points: '50,50', radius: 7, zoom: 3, color: '#7c3aed', hold: 2200 };
    var NS = 'http://www.w3.org/2000/svg';

    var css =
      '.dzc{position:relative;--dzc-c:#7c3aed}' +
      '.dzc>:first-child{display:block;width:100%;height:auto}' +
      '.dzc-svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible;pointer-events:none}' +
      '.dzc-ring{position:absolute;box-sizing:border-box;border-radius:50%;border:2px solid var(--dzc-c);box-shadow:0 0 0 2px rgba(255,255,255,.85),inset 0 0 0 1px rgba(255,255,255,.6);pointer-events:none}' +
      '.dzc-lens{position:absolute;box-sizing:border-box;border-radius:50%;overflow:hidden;background:#fff;border:3px solid var(--dzc-c);box-shadow:0 0 0 3px #fff,0 12px 30px rgba(50,20,100,.3);pointer-events:none}' +
      '.dzc-zoom{display:block;position:absolute;left:0;top:0;transform-origin:0 0;max-width:none!important}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function mk(tag, attrs, parent) {
      var n = document.createElementNS(NS, tag);
      for (var k in attrs) n.setAttribute(k, attrs[k]);
      if (parent) parent.appendChild(n);
      return n;
    }

    function setup(el) {
      var d = el.dataset, color = d.color || DEFAULTS.color;
      var s = (el._dzc = { visible: false, running: false, index: 0 });
      el.classList.add('dzc');
      el.style.setProperty('--dzc-c', color);
      s.pic = el.firstElementChild;
      s.points = (d.points || DEFAULTS.points).split(';').map(function (p) {
        var xy = p.split(',');
        return { x: +xy[0] || 0, y: +xy[1] || 0 };
      });
      var svg = mk('svg', { class: 'dzc-svg', 'aria-hidden': 'true' });
      s.l1 = mk('path', { fill: 'none', stroke: color, 'stroke-width': 1.5, 'stroke-linecap': 'round' }, svg);
      s.l2 = mk('path', { fill: 'none', stroke: color, 'stroke-width': 1.5, 'stroke-linecap': 'round' }, svg);
      s.ring = document.createElement('div');
      s.ring.className = 'dzc-ring';
      s.lens = document.createElement('div');
      s.lens.className = 'dzc-lens';
      s.zoom = s.pic.cloneNode(true);
      s.zoom.classList.add('dzc-zoom');
      s.zoom.setAttribute('aria-hidden', 'true');
      s.zoom.removeAttribute('id');
      s.zoom.setAttribute('alt', '');
      s.lens.appendChild(s.zoom);
      s.lens.setAttribute('aria-hidden', 'true');
      s.ring.setAttribute('aria-hidden', 'true');
      el.appendChild(svg);
      el.appendChild(s.ring);
      el.appendChild(s.lens);
      layout(el);
      new ResizeObserver(function () { layout(el); }).observe(el);
      if (s.pic.tagName === 'IMG' && !s.pic.complete) s.pic.addEventListener('load', function () { layout(el); });
    }

    // Place the small ring, the big lens and the two lines joining them.
    function layout(el) {
      var s = el._dzc, d = el.dataset;
      var W = s.pic.getBoundingClientRect().width, H = s.pic.getBoundingClientRect().height;
      if (!W || !H) return;
      var p = s.points[s.index];
      var r = (W * (+d.radius || DEFAULTS.radius)) / 100;
      var m = Math.max(6, W * 0.025);
      var R = Math.max(r * 1.5, Math.min(r * (+d.zoom || DEFAULTS.zoom), H / 2 - m, W * 0.26));
      var px = (p.x / 100) * W, py = (p.y / 100) * H;
      var cx = px < W / 2 ? W - R - m : R + m;
      var cy = Math.max(R + m, Math.min(H - R - m, py));
      var k = R / r;
      s.geo = { px: px, py: py, r: r, cx: cx, cy: cy, R: R, k: k };
      Object.assign(s.ring.style, { left: px - r + 'px', top: py - r + 'px', width: r * 2 + 'px', height: r * 2 + 'px' });
      Object.assign(s.lens.style, { left: cx - R + 'px', top: cy - R + 'px', width: R * 2 + 'px', height: R * 2 + 'px' });
      s.zoom.style.width = W + 'px';
      s.zoom.style.height = H + 'px';
      setZoom(s, k);
      // The two outside tangents between the circles.
      var dx = cx - px, dy = cy - py, dist = Math.sqrt(dx * dx + dy * dy) || 1;
      var th = Math.atan2(dy, dx), ph = Math.acos(Math.max(-1, Math.min(1, (r - R) / dist)));
      [s.l1, s.l2].forEach(function (l, i) {
        var a = th + (i ? ph : -ph);
        var x1 = px + Math.cos(a) * r, y1 = py + Math.sin(a) * r;
        var x2 = cx + Math.cos(a) * R, y2 = cy + Math.sin(a) * R;
        l.setAttribute('d', 'M' + x1 + ',' + y1 + ' L' + x2 + ',' + y2);
      });
    }

    function setZoom(s, k) {
      var g = s.geo;
      s.zoom.style.transform = 'translate(' + (g.R - g.px * k) + 'px,' + (g.R - g.py * k) + 'px) scale(' + k + ')';
    }

    function open(el, cb) {
      var s = el._dzc, g = s.geo;
      anime.animate(s.ring, { opacity: [0, 1], scale: [2.2, 1], duration: 500, ease: 'out(3)' });
      [s.l1, s.l2].forEach(function (l) {
        anime.utils.set(l, { opacity: 1 });
        anime.animate(anime.svg.createDrawable(l), { draw: ['0 0', '0 1'], duration: 600, delay: 450, ease: 'inOut(2)' });
      });
      // The lens lifts off the small ring and grows into place, zooming in as it goes.
      var z = { k: g.k * 0.55 };
      anime.animate(s.lens, {
        opacity: [0, 1], x: [g.px - g.cx, 0], y: [g.py - g.cy, 0], scale: [g.r / g.R, 1],
        duration: 750, delay: 450, ease: 'inOut(3)',
      });
      anime.animate(z, { k: g.k, duration: 1100, delay: 450, ease: 'out(3)', onUpdate: function () { setZoom(s, z.k); }, onComplete: cb });
    }

    function close(el, cb) {
      var s = el._dzc, g = s.geo;
      anime.animate(s.lens, { opacity: 0, x: g.px - g.cx, y: g.py - g.cy, scale: g.r / g.R, duration: 550, ease: 'inOut(3)' });
      anime.animate([s.l1, s.l2], { opacity: 0, duration: 400 });
      anime.animate(s.ring, { opacity: 0, scale: 0.6, duration: 350, delay: 400, ease: 'in(2)', onComplete: cb });
    }

    function cycle(el) {
      var s = el._dzc, d = el.dataset, hold = +d.hold || DEFAULTS.hold;
      s.running = true;
      layout(el);
      open(el, function () {
        if (d.loop == null && s.index === s.points.length - 1) { s.running = false; s.done = true; return; }
        setTimeout(function () {
          close(el, function () {
            s.index = (s.index + 1) % s.points.length;
            setTimeout(function () {
              if (s.visible && (d.loop != null || s.index > 0)) cycle(el);
              else s.running = false;
            }, 350);
          });
        }, hold);
      });
    }

    function init() {
      var still = reduced();
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var s = e.target._dzc;
          s.visible = e.isIntersecting;
          if (s.visible && !s.running && !s.done) cycle(e.target);
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-detail-zoom-callout]').forEach(function (el) {
        if (el._dzc || !el.firstElementChild) return;
        setup(el);
        if (still) return;
        var s = el._dzc;
        anime.utils.set([s.ring, s.lens], { opacity: 0 });
        anime.utils.set([s.l1, s.l2], { opacity: 0 });
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-detail-zoom-callout data-points="30,72;73,55" data-loop>
  <img src="village.jpg" alt="A cottage by a lake">
</div>