← Cards & images

Image Hotspots

Pulsing dots over a picture, each marking something worth a closer look. Tap one and its label pops out of the dot, placed so it never runs off the picture. Tap again, tap elsewhere or press Escape to close it.

Snow lineAbove 2,400 m, snow lies all year Midday sunTwelve hours of light in June Pine forestPaths for walking and cycling Glacial lakeA brisk 12°C in August

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-image-hotspots to a box holding your image.
  3. Add a <span data-hotspot> for each spot, with its label inside and data-x and data-y placing it, in % of the image’s width and height.

Options

Add any of these attributes to the same element.

data-colorColour of the dots. Default #6a0dad.
data-hoverOpen labels on hover too, for mouse users.
data-autoplayMilliseconds: open each label in turn until someone interacts.

Each dot is a real button with aria-expanded, named after its label, so keyboard and screen-reader users can open them too. A <b> at the start of a label becomes its title. From code: animacious.hotspot(element, 0) opens the first, and -1 closes it.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Image Hotspots: pulsing dots over a picture. Tap or click one and a
    // label pops out of it, placed so it stays inside the picture; tap again,
    // tap elsewhere or press Escape to close it. Only one is open at a time.
    // Usage: <div data-image-hotspots>
    //          <img src="room.jpg" alt="Living room">
    //          <span data-hotspot data-x="32" data-y="58"><b>Oak table</b> Seats six</span>
    //          …
    //        </div>
    // data-x and data-y place each hotspot, in % of the picture's width and height.
    // Optional: data-color (dot colour, default #6a0dad), data-autoplay (ms:
    // open each label in turn until someone interacts), data-hover (open on
    // hover as well, for mouse users).
    // Script use: animacious.hotspot(el, index) (or -1 to close).
    var DEFAULTS = { color: '#6a0dad' };

    var css =
      '.ihs{position:relative;--ihs-c:#6a0dad}' +
      '.ihs>img,.ihs>svg,.ihs>picture{display:block;width:100%;height:auto}' +
      '.ihs-dot{position:absolute;z-index:1;width:26px;height:26px;margin:-13px 0 0 -13px;padding:0;border:0;border-radius:50%;background:none;cursor:pointer;-webkit-tap-highlight-color:transparent}' +
      '.ihs-dot::before{content:"";position:absolute;inset:0;border-radius:50%;background:var(--ihs-c);opacity:.35;animation:ihs-pulse 1.8s ease-out var(--ihs-d,0s) infinite}' +
      '.ihs-dot>i{position:absolute;inset:6px;border-radius:50%;background:#fff;box-shadow:0 0 0 3px var(--ihs-c),0 2px 8px rgba(0,0,0,.35);transition:transform .25s}' +
      '.ihs-dot>i::before,.ihs-dot>i::after{content:"";position:absolute;left:50%;top:50%;width:8px;height:2px;margin:-1px 0 0 -4px;border-radius:1px;background:var(--ihs-c);transition:transform .25s}' +
      '.ihs-dot>i::after{transform:rotate(90deg)}' +
      '.ihs-dot[aria-expanded="true"]>i{transform:rotate(45deg) scale(1.1)}' +
      '.ihs-dot[aria-expanded="true"]::before{animation:none;opacity:0}' +
      '.ihs-dot:focus-visible{outline:2px solid #fff;outline-offset:3px}' +
      '@keyframes ihs-pulse{0%{transform:scale(.6);opacity:.55}100%{transform:scale(1.9);opacity:0}}' +
      '@media (prefers-reduced-motion:reduce){.ihs-dot::before{animation:none}}' +
      '.ihs-tip{position:absolute;z-index:2;left:0;top:0;width:max-content;max-width:min(220px,70%);padding:8px 11px;border-radius:10px;background:#fff;color:#1f1f24;font-size:13px;line-height:1.35;text-align:left;box-shadow:0 10px 28px rgba(15,15,30,.25);opacity:0;visibility:hidden;pointer-events:none}' +
      '.ihs-tip b{display:block;font-size:14px}' +
      '.ihs-tip.ihs-open{visibility:visible;pointer-events:auto}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function setup(el) {
      var d = el.dataset;
      var spots = Array.prototype.slice.call(el.querySelectorAll('[data-hotspot]'));
      if (!spots.length) return;
      el.classList.add('ihs');
      if (d.color) el.style.setProperty('--ihs-c', d.color);
      var thumb = !!el.closest('[aria-hidden="true"]');
      var s = el._ihsS = { items: [], open: -1, timer: 0, visible: false, next: 0 };

      spots.forEach(function (sp, i) {
        var x = +sp.dataset.x || 50, y = +sp.dataset.y || 50;
        var dot = document.createElement(thumb ? 'span' : 'button');
        if (!thumb) dot.type = 'button';
        dot.className = 'ihs-dot';
        dot.style.left = x + '%';
        dot.style.top = y + '%';
        dot.appendChild(document.createElement('i'));
        var tip = document.createElement('div');
        tip.className = 'ihs-tip';
        tip.id = 'ihs-' + (++uid);
        tip.innerHTML = sp.innerHTML;
        if (!thumb) {
          dot.setAttribute('aria-expanded', 'false');
          dot.setAttribute('aria-controls', tip.id);
          dot.setAttribute('aria-label', sp.getAttribute('aria-label') || sp.textContent.trim());
        } else dot.setAttribute('aria-expanded', 'false');
        sp.parentNode.removeChild(sp);
        el.appendChild(dot);
        el.appendChild(tip);
        dot.addEventListener('click', function (e) {
          e.stopPropagation();
          stop(el);
          open(el, s.open === i ? -1 : i);
        });
        if (d.hover !== undefined) {
          dot.addEventListener('pointerenter', function (e) { if (e.pointerType === 'mouse') { stop(el); open(el, i); } });
        }
        s.items.push({ dot: dot, tip: tip, x: x, y: y });
      });
      // Offset each dot's pulse so they don't throb in step.
      s.items.forEach(function (it, i) { it.dot.style.setProperty('--ihs-d', (-i * 0.45) + 's'); });

      document.addEventListener('click', function (e) { if (s.open >= 0 && !el.contains(e.target)) open(el, -1); });
      el.addEventListener('click', function (e) { if (s.open >= 0 && !s.items[s.open].tip.contains(e.target)) open(el, -1); });
      el.addEventListener('keydown', function (e) {
        if (e.key === 'Escape' && s.open >= 0) { var dot = s.items[s.open].dot; open(el, -1); dot.focus(); }
      });
      new ResizeObserver(function () { if (s.open >= 0) position(el, s.open); }).observe(el);

      if (d.autoplay !== undefined && d.autoplay !== 'false' && !reduced()) {
        new IntersectionObserver(function (en) { s.visible = en[0].isIntersecting; }, { threshold: 0.3 }).observe(el);
        s.timer = setInterval(function () {
          if (!s.visible) return;
          // Open each in turn, with a short pause with all closed between.
          if (s.open >= 0) { open(el, -1); return; }
          open(el, s.next);
          s.next = (s.next + 1) % s.items.length;
        }, (+d.autoplay || 1600) / 2);
        el.addEventListener('pointerdown', function () { stop(el); });
      }
    }

    function stop(el) {
      var s = el._ihsS;
      if (s.timer) { clearInterval(s.timer); s.timer = 0; }
    }

    // Put the label above the dot, or below it near the top, kept inside the picture.
    function position(el, i) {
      var s = el._ihsS, it = s.items[i];
      var W = el.clientWidth, H = el.clientHeight;
      var px = it.x / 100 * W, py = it.y / 100 * H;
      var tw = it.tip.offsetWidth, th = it.tip.offsetHeight, gap = 18, m = 6;
      var below = py - gap - th < m;
      var left = Math.max(m, Math.min(W - tw - m, px - tw / 2));
      var top = below ? py + gap : py - gap - th;
      top = Math.max(m, Math.min(H - th - m, top));
      it.tip.style.left = left.toFixed(1) + 'px';
      it.tip.style.top = top.toFixed(1) + 'px';
      it.tip.style.transformOrigin = (px - left).toFixed(1) + 'px ' + (below ? 0 : th) + 'px';
      return below;
    }

    function open(el, i) {
      var s = el._ihsS;
      if (s.open === i) return;
      if (s.open >= 0) {
        var old = s.items[s.open];
        old.dot.setAttribute('aria-expanded', 'false');
        if (reduced()) { old.tip.classList.remove('ihs-open'); old.tip.style.opacity = 0; }
        else anime.animate(old.tip, {
          opacity: 0, scale: 0.85, duration: 160, ease: 'in(2)',
          onComplete: function () { if (s.items.indexOf(old) !== s.open) old.tip.classList.remove('ihs-open'); }
        });
      }
      s.open = i;
      if (i < 0) return;
      var it = s.items[i];
      it.tip.classList.add('ihs-open');
      var below = position(el, i);
      it.dot.setAttribute('aria-expanded', 'true');
      if (reduced()) { it.tip.style.opacity = 1; return; }
      anime.animate(it.tip, {
        opacity: { from: 0, to: 1, duration: 180 },
        scale: { from: 0.6, to: 1 },
        y: { from: below ? -8 : 8, to: 0 },
        duration: 420, ease: 'outElastic(1, .75)'
      });
    }

    function init() {
      document.querySelectorAll('[data-image-hotspots]').forEach(function (el) {
        if (el._ihs) return;
        el._ihs = true;
        setup(el);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.hotspot = function (el, i) { if (el._ihsS) { stop(el); open(el, i); } };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-image-hotspots>
  <img src="lake.jpg" alt="A mountain lake">
  <span data-hotspot data-x="41" data-y="25"><b>Snow line</b> Above 2,400 m</span>
  <span data-hotspot data-x="34" data-y="86"><b>Glacial lake</b> 12°C in August</span>
</div>