← Diagrams & maps

Dimension Lines

Measurements draw in like a technical drawing. Extension lines run out from the edges of your picture, each dimension line grows from its middle out to both ends, the arrowheads snap on and the size counts up to its value.

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap your picture in an element with data-dimension-lines.
  3. Add the sizes to show with data-width and data-height, such as data-width="210 cm".

Options

Add any of these attributes to the same element.

data-width / data-heightThe sizes to show, with any units. Leave one out to skip that line.
data-x-sideWhere the width goes: bottom or top. Default bottom.
data-y-sideWhere the height goes: right or left. Default right.
data-colorColour of the lines and sizes. Default #7c3aed.
data-durationTime to draw each line. Default 1100 ms.
data-loopClear and measure again.

The lines fit the edges of the picture, so trim any empty space around it. Text size follows the element’s font-size.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Dimension Lines: measurement lines draw in around a picture, like a
    // technical drawing. Extension lines run out from its edges, each
    // dimension line grows from its middle to both ends, the arrowheads snap
    // on and the size counts up to its value.
    // Usage:
    //   <div data-dimension-lines data-width="210 cm" data-height="85 cm">
    //     <img src="sofa.png" alt="Sofa">
    //   </div>
    // The first child is the thing being measured; the lines fit its edges.
    // Optional: data-width and data-height (the sizes shown; leave one out to
    // skip that line), data-x-side (bottom or top, default bottom),
    // data-y-side (right or left, default right), data-color (default
    // #7c3aed), data-duration (ms for each line, default 1100), data-loop
    // (clear and measure again).
    var DEFAULTS = { color: '#7c3aed', duration: 1100 };
    var NS = 'http://www.w3.org/2000/svg';

    var css =
      '.dml{position:relative;display:inline-block;box-sizing:border-box;max-width:100%;padding:.5em;--dml-c:#7c3aed}' +
      '.dml-b{padding-bottom:3em}.dml-t{padding-top:3em}.dml-r,.dml-l{padding-left:3em;padding-right:3em}' +
      '.dml>:first-child{display:block;max-width:100%}' +
      '.dml-svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible;pointer-events:none}' +
      '.dml-text{font-weight:700;font-size:.95em;font-variant-numeric:tabular-nums;fill:var(--dml-c);paint-order:stroke;stroke:rgba(255,255,255,.85);stroke-width:4px;stroke-linejoin:round}';
    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;
    }

    // "146.7 mm" -> { n: 146.7, dp: 1, pre: '', post: ' mm' }
    function parseSize(str) {
      var m = String(str).match(/^(\D*?)(\d+(?:[.,]\d+)?)(.*)$/);
      if (!m) return { text: str };
      var num = m[2].replace(',', '.');
      return { n: parseFloat(num), dp: (num.split('.')[1] || '').length, pre: m[1], post: m[3], comma: m[2].indexOf(',') > -1 };
    }
    function fmt(p, v) {
      if (p.n == null) return p.text;
      var t = v.toFixed(p.dp);
      return p.pre + (p.comma ? t.replace('.', ',') : t) + p.post;
    }

    function setup(el) {
      var d = el.dataset, color = d.color || DEFAULTS.color;
      var s = (el._dml = { dims: [], visible: false, running: false });
      s.target = el.firstElementChild;
      el.classList.add('dml');
      el.style.setProperty('--dml-c', color);
      var svg = mk('svg', { class: 'dml-svg', 'aria-hidden': 'true' });
      var g = mk('g', { fill: 'none', stroke: color, 'stroke-linecap': 'round' }, svg);
      var said = [];
      if (d.width) {
        el.classList.add(d.xSide === 'top' ? 'dml-t' : 'dml-b');
        s.dims.push({ axis: 'x', size: parseSize(d.width) });
        said.push(d.width + ' wide');
      }
      if (d.height) {
        el.classList.add(d.ySide === 'left' ? 'dml-l' : 'dml-r');
        s.dims.push({ axis: 'y', size: parseSize(d.height) });
        said.push(d.height + ' tall');
      }
      s.dims.forEach(function (dm) {
        dm.ext1 = mk('path', { 'stroke-width': 1, opacity: 0.6 }, g);
        dm.ext2 = mk('path', { 'stroke-width': 1, opacity: 0.6 }, g);
        dm.half1 = mk('path', { 'stroke-width': 1.5 }, g);
        dm.half2 = mk('path', { 'stroke-width': 1.5 }, g);
        dm.head1 = mk('path', { fill: color, stroke: 'none' }, g);
        dm.head2 = mk('path', { fill: color, stroke: 'none' }, g);
        dm.text = mk('text', { class: 'dml-text', 'text-anchor': 'middle', 'dominant-baseline': 'central' }, svg);
        dm.text.textContent = fmt(dm.size, dm.size.n == null ? 0 : dm.size.n);
      });
      el.appendChild(svg);
      if (said.length && !el.getAttribute('aria-label')) {
        var alt = s.target && (s.target.getAttribute('alt') || s.target.getAttribute('aria-label'));
        el.setAttribute('role', 'img');
        el.setAttribute('aria-label', (alt ? alt + ': ' : 'Measured: ') + said.join(' by '));
      }
      layout(el);
      new ResizeObserver(function () { layout(el); }).observe(el);
      if (s.target && s.target.tagName === 'IMG' && !s.target.complete) s.target.addEventListener('load', function () { layout(el); });
    }

    function layout(el) {
      var s = el._dml, d = el.dataset;
      if (!s.target) return;
      var fs = parseFloat(getComputedStyle(el).fontSize) || 14;
      var tr = s.target.getBoundingClientRect(), er = el.getBoundingClientRect();
      var x0 = tr.left - er.left, y0 = tr.top - er.top;
      var x1 = x0 + tr.width, y1 = y0 + tr.height;
      var gap = 4, off = fs * 1.4, ah = 7, aw = 3.2;
      s.dims.forEach(function (dm) {
        var a, b, ext1, ext2, line, h1, h2, tx, ty, rot = 0;
        if (dm.axis === 'x') {
          var top = d.xSide === 'top', ly = top ? y0 - off : y1 + off, dir = top ? -1 : 1;
          ext1 = 'M' + x0 + ',' + (top ? y0 - gap : y1 + gap) + ' L' + x0 + ',' + (ly + dir * 5);
          ext2 = 'M' + x1 + ',' + (top ? y0 - gap : y1 + gap) + ' L' + x1 + ',' + (ly + dir * 5);
          var mx = (x0 + x1) / 2;
          dm.half1.setAttribute('d', 'M' + mx + ',' + ly + ' L' + x0 + ',' + ly);
          dm.half2.setAttribute('d', 'M' + mx + ',' + ly + ' L' + x1 + ',' + ly);
          h1 = 'M' + x0 + ',' + ly + ' l' + ah + ',' + -aw + ' v' + aw * 2 + ' Z';
          h2 = 'M' + x1 + ',' + ly + ' l' + -ah + ',' + -aw + ' v' + aw * 2 + ' Z';
          tx = mx; ty = ly + dir * fs * 0.95;
          dm.origin = [mx, ly];
        } else {
          var left = d.ySide === 'left', lx = left ? x0 - off : x1 + off, dr = left ? -1 : 1;
          ext1 = 'M' + (left ? x0 - gap : x1 + gap) + ',' + y0 + ' L' + (lx + dr * 5) + ',' + y0;
          ext2 = 'M' + (left ? x0 - gap : x1 + gap) + ',' + y1 + ' L' + (lx + dr * 5) + ',' + y1;
          var my = (y0 + y1) / 2;
          dm.half1.setAttribute('d', 'M' + lx + ',' + my + ' L' + lx + ',' + y0);
          dm.half2.setAttribute('d', 'M' + lx + ',' + my + ' L' + lx + ',' + y1);
          h1 = 'M' + lx + ',' + y0 + ' l' + -aw + ',' + ah + ' h' + aw * 2 + ' Z';
          h2 = 'M' + lx + ',' + y1 + ' l' + -aw + ',' + -ah + ' h' + aw * 2 + ' Z';
          tx = lx + dr * fs * 0.95; ty = my; rot = -90;
          dm.origin = [lx, my];
        }
        dm.ext1.setAttribute('d', ext1);
        dm.ext2.setAttribute('d', ext2);
        dm.head1.setAttribute('d', h1);
        dm.head2.setAttribute('d', h2);
        dm.text.setAttribute('transform', 'translate(' + tx + ',' + ty + ')' + (rot ? ' rotate(' + rot + ')' : ''));
      });
    }

    function measure(el, dm, delay) {
      var dur = +el.dataset.duration || DEFAULTS.duration;
      [dm.ext1, dm.ext2].forEach(function (p) {
        anime.animate(anime.svg.createDrawable(p), { draw: ['0 0', '0 1'], duration: dur * 0.3, delay: delay, ease: 'out(2)' });
      });
      [dm.half1, dm.half2].forEach(function (p) {
        anime.animate(anime.svg.createDrawable(p), { draw: ['0 0', '0 1'], duration: dur * 0.6, delay: delay + dur * 0.25, ease: 'inOut(3)' });
      });
      anime.animate([dm.head1, dm.head2], { opacity: [0, 1], duration: 150, delay: delay + dur * 0.8 });
      anime.animate(dm.text, { opacity: [0, 1], duration: 300, delay: delay + dur * 0.3 });
      if (dm.size.n != null) {
        var o = { v: 0 };
        anime.animate(o, {
          v: dm.size.n, duration: dur * 0.75, delay: delay + dur * 0.3, ease: 'out(3)',
          onUpdate: function () { dm.text.textContent = fmt(dm.size, o.v); },
        });
      }
    }

    function clear(el, dm) {
      [dm.ext1, dm.ext2, dm.half1, dm.half2, dm.head1, dm.head2, dm.text].forEach(function (p) { anime.animate(p, { opacity: 0, duration: 400 }); });
    }

    function reset(dm) {
      [dm.ext1, dm.ext2, dm.half1, dm.half2].forEach(function (p) {
        anime.utils.set(p, { opacity: p === dm.ext1 || p === dm.ext2 ? 0.6 : 1 });
        anime.utils.set(anime.svg.createDrawable(p), { draw: '0 0' });
      });
      anime.utils.set([dm.head1, dm.head2, dm.text], { opacity: 0 });
    }

    function play(el) {
      var s = el._dml, d = el.dataset, dur = +d.duration || DEFAULTS.duration;
      s.running = true;
      s.dims.forEach(function (dm, i) { measure(el, dm, i * dur * 0.75); });
      var end = s.dims.length * dur * 0.75 + dur * 0.4;
      setTimeout(function () {
        if (d.loop == null) { s.running = false; return; }
        setTimeout(function () {
          s.dims.forEach(function (dm) { clear(el, dm); });
          setTimeout(function () {
            s.dims.forEach(reset);
            s.running = false;
            if (s.visible) play(el);
          }, 600);
        }, 2200);
      }, end);
    }

    function init() {
      var still = reduced();
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var s = e.target._dml;
          s.visible = e.isIntersecting;
          if (s.visible && !s.running) play(e.target);
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-dimension-lines]').forEach(function (el) {
        if (el._dml) return;
        setup(el);
        if (still) return;
        el._dml.dims.forEach(reset);
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-dimension-lines data-width="210 cm" data-height="85 cm">
  <img src="sofa.png" alt="Sofa" width="320">
</div>