← Diagrams & maps

Callout Labels

Point out the parts of a product. One by one, a dot pops onto the picture with a pulse, a leader line draws out from it and bends level, and its label slides in at the end. Labels sit either side of the picture and spread out so they never overlap.

10:09 Titanium case Activity rings Sport band Sapphire glass Digital crown Side button

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-callout-labels to a container. Put the picture first (an <img> or inline SVG), then one child per label.
  3. Place each label’s point with data-x and data-y, from 0 to 100 across and down the picture.

Options

Add any of these attributes to the same element.

data-x / data-yOn a label: where its point sits on the picture, 0–100 from the top left.
data-sideOn a label: left or right. Default: the side its point is nearest.
data-noteOn a label: a smaller second line.
data-colorColour of the lines and dots. Default #7c3aed.
data-staggerGap between labels. Default 450 ms.
data-loopDraw the lines back in and out again.

The picture takes the middle of the width and the labels share the sides, so short labels suit phones best.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Callout Labels: leader lines draw out from points on a product picture
    // to labels at either side. Each point pops in with a pulse, its line
    // draws out to the side and bends level, and the label slides in at the
    // end of it, one after another.
    // Usage:
    //   <div data-callout-labels>
    //     <img src="watch.png" alt="Smart watch">
    //     <span data-x="84" data-y="45">Digital crown</span>
    //     <span data-x="22" data-y="33">Titanium case</span>
    //   </div>
    // The first child is the picture (an <img>, <svg> or anything else). Every
    // other child is a label; data-x and data-y place its point on the picture,
    // from 0 to 100 (0, 0 is the top left).
    // Optional: data-side on a label (left or right; default: the side its
    // point is nearest), data-note on a label (a smaller second line),
    // data-color (lines and points, default #7c3aed), data-stagger (ms between
    // labels, default 450), data-loop (draw them back in and out again).
    var DEFAULTS = { color: '#7c3aed', stagger: 450 };
    var NS = 'http://www.w3.org/2000/svg';

    var css =
      '.cll{position:relative;display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.25fr) minmax(0,1fr);align-items:center;width:100%;--cll-c:#7c3aed}' +
      '.cll-pic{grid-column:2;position:relative;min-width:0}' +
      '.cll-pic>img,.cll-pic>svg{display:block;width:100%;height:auto}' +
      '.cll-svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible;pointer-events:none}' +
      '.cll-label{position:absolute;box-sizing:border-box;line-height:1.2;font-weight:700}' +
      '.cll-label small{display:block;font-size:.8em;font-weight:500;opacity:.7}' +
      '.cll-l{text-align:right}' +
      '.cll-dot{transform-box:fill-box;transform-origin:center}';
    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._cll = { labels: [], visible: false, running: false });
      el.classList.add('cll');
      el.style.setProperty('--cll-c', color);
      var kids = Array.from(el.children);
      var pic = kids.filter(function (k) { return !k.hasAttribute('data-x'); })[0];
      var wrap = document.createElement('div');
      wrap.className = 'cll-pic';
      if (pic) { el.replaceChild(wrap, pic); wrap.appendChild(pic); } else el.appendChild(wrap);
      s.pic = wrap;
      var svg = mk('svg', { class: 'cll-svg', 'aria-hidden': 'true' });
      s.lineLayer = mk('g', { fill: 'none', stroke: color, 'stroke-width': 1.5, 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, svg);
      s.dotLayer = mk('g', {}, svg);
      kids.forEach(function (k) {
        if (!k.hasAttribute('data-x')) return;
        var label = document.createElement('div');
        label.className = 'cll-label';
        label.textContent = k.textContent.trim();
        if (k.getAttribute('data-note')) {
          var sm = document.createElement('small');
          sm.textContent = k.getAttribute('data-note');
          label.appendChild(sm);
        }
        var x = +k.getAttribute('data-x') || 0, y = +k.getAttribute('data-y') || 0;
        var side = k.getAttribute('data-side') || (x < 50 ? 'left' : 'right');
        label.classList.add(side === 'left' ? 'cll-l' : 'cll-r');
        el.replaceChild(label, k);
        s.labels.push({
          el: label, x: x, y: y, left: side === 'left', text: k.textContent.trim(),
          line: mk('path', {}, s.lineLayer),
          ring: mk('circle', { r: 4, fill: 'none', stroke: color, 'stroke-width': 1.5, opacity: 0 }, s.dotLayer),
          dot: mk('circle', { class: 'cll-dot', r: 4, fill: color, stroke: '#fff', 'stroke-width': 2 }, s.dotLayer),
        });
      });
      el.appendChild(svg);
      s.labels.forEach(function (l) { l.el.setAttribute('aria-hidden', 'true'); });
      if (pic && !pic.getAttribute('aria-label') && !pic.getAttribute('alt')) pic.setAttribute('aria-hidden', 'true');
      el.setAttribute('role', 'img');
      if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Labelled picture: ' + s.labels.map(function (l) { return l.text; }).join(', '));
      layout(el);
      new ResizeObserver(function () { layout(el); }).observe(el);
      if (pic && pic.tagName === 'IMG' && !pic.complete) pic.addEventListener('load', function () { layout(el); });
    }

    // Spread one side's labels out so they don't overlap, keeping each near its point.
    function spread(list, H) {
      list.sort(function (a, b) { return a.py - b.py; });
      var gap = 6, i;
      list.forEach(function (l) { l.cy = l.py; });
      for (i = 1; i < list.length; i++) list[i].cy = Math.max(list[i].cy, list[i - 1].cy + (list[i - 1].h + list[i].h) / 2 + gap);
      var over = list.length ? list[list.length - 1].cy + list[list.length - 1].h / 2 - H : 0;
      if (over > 0) {
        list[list.length - 1].cy -= over;
        for (i = list.length - 2; i >= 0; i--) list[i].cy = Math.min(list[i].cy, list[i + 1].cy - (list[i].h + list[i + 1].h) / 2 - gap);
      }
      list.forEach(function (l) { l.cy = Math.max(l.h / 2, l.cy); });
    }

    function layout(el) {
      var s = el._cll;
      var W = el.clientWidth;
      if (!W) return;
      var pr = s.pic.getBoundingClientRect(), er = el.getBoundingClientRect();
      var px0 = pr.left - er.left, py0 = pr.top - er.top, pw = pr.width, ph = pr.height;
      var H = Math.max(el.clientHeight, ph);
      var pad = 10;
      var lefts = [], rights = [];
      s.labels.forEach(function (l) {
        l.px = px0 + (l.x / 100) * pw;
        l.py = py0 + (l.y / 100) * ph;
        l.el.style.width = Math.max(40, (l.left ? px0 : W - px0 - pw) - pad) + 'px';
        if (l.left) l.el.style.left = '0px';
        else l.el.style.left = px0 + pw + pad + 'px';
        l.h = l.el.offsetHeight;
        (l.left ? lefts : rights).push(l);
      });
      spread(lefts, H);
      spread(rights, H);
      s.labels.forEach(function (l) {
        l.el.style.top = l.cy - l.h / 2 + 'px';
        // The line runs from the point, bends level, and stops just short of the label.
        var ax = l.left ? px0 - pad + 4 : px0 + pw + pad - 4;
        var kx = l.left ? Math.min(l.px - 8, ax + 18) : Math.max(l.px + 8, ax - 18);
        l.line.setAttribute('d', 'M' + l.px + ',' + l.py + ' L' + kx + ',' + l.cy + ' L' + ax + ',' + l.cy);
        [l.dot, l.ring].forEach(function (c) { c.setAttribute('cx', l.px); c.setAttribute('cy', l.py); });
      });
    }

    function show(el, l, delay) {
      anime.animate(l.dot, { scale: [0, 1.4, 1], opacity: [0, 1], duration: 450, delay: delay, ease: 'out(3)' });
      anime.animate(l.ring, { r: [4, 16], opacity: [0.8, 0], duration: 900, delay: delay + 100, ease: 'out(2)' });
      anime.animate(anime.svg.createDrawable(l.line), { draw: ['0 0', '0 1'], duration: 500, delay: delay + 200, ease: 'inOut(2)' });
      anime.animate(l.el, { opacity: [0, 1], x: [l.left ? 12 : -12, 0], duration: 450, delay: delay + 550, ease: 'out(3)' });
    }

    function hide(el, l, delay) {
      anime.animate(l.el, { opacity: 0, x: l.left ? 8 : -8, duration: 300, delay: delay, ease: 'in(2)' });
      anime.animate(anime.svg.createDrawable(l.line), { draw: '0 0', duration: 400, delay: delay + 150, ease: 'inOut(2)' });
      anime.animate(l.dot, { scale: 0, opacity: 0, duration: 300, delay: delay + 450 });
    }

    function play(el) {
      var s = el._cll, d = el.dataset, stagger = +d.stagger || DEFAULTS.stagger;
      s.running = true;
      var order = s.labels.slice().sort(function (a, b) { return a.y - b.y; });
      order.forEach(function (l, i) { show(el, l, i * stagger); });
      var end = order.length * stagger + 1100;
      setTimeout(function () {
        if (d.loop == null) { s.running = false; return; }
        // Each point pulses once more, then everything draws back in.
        order.forEach(function (l, i) { anime.animate(l.ring, { r: [4, 14], opacity: [0.7, 0], duration: 800, delay: i * 120 }); });
        setTimeout(function () {
          order.forEach(function (l, i) { hide(el, l, i * 70); });
          setTimeout(function () { s.running = false; if (s.visible) play(el); }, order.length * 70 + 1000);
        }, 2600);
      }, end);
    }

    function init() {
      var still = reduced();
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var s = e.target._cll;
          s.visible = e.isIntersecting;
          if (s.visible && !s.running) play(e.target);
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-callout-labels]').forEach(function (el) {
        if (el._cll) return;
        setup(el);
        if (still) return;
        el._cll.labels.forEach(function (l) {
          anime.utils.set([l.el, l.dot], { opacity: 0 });
          anime.utils.set(anime.svg.createDrawable(l.line), { draw: '0 0' });
        });
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-callout-labels style="max-width: 600px">
  <img src="watch.png" alt="Smart watch">
  <span data-x="20" data-y="35">Titanium case</span>
  <span data-x="56" data-y="27">Sapphire glass</span>
  <span data-x="82" data-y="45" data-note="Turn to scroll">Digital crown</span>
</div>