← Hover & cursor

Dock Magnification

A row of icons swells as the pointer glides along it, like the macOS dock: the nearest icon grows the most, its neighbours a little less, and they push each other apart. A label pops up over the icon you’re on, and a click gives it a bounce.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-dock-magnification to a row of icon links. Labels come from each one’s aria-label, title or data-label.

Options

Add any of these attributes to the same element.

data-scaleHow big the nearest icon gets. Default 1.8.
data-rangeHow many icons either side of the pointer grow too. Default 2.5.
data-labels="false"No labels.
data-autoplayA pretend pointer sweeps along the dock while a real one isn’t over it. Handy for demos and touch screens.

Icons grow upwards, so leave some room above the dock. Size the icons with CSS on the links.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Dock Magnification: icons in a row swell as the pointer passes over them,
    // like the macOS dock. The nearest icon grows the most and its neighbours
    // less, pushing each other apart, and a label pops up over the one you're
    // on. Click an icon and it gives a little bounce.
    // Usage: <nav data-dock-magnification>
    //          <a href="/" aria-label="Home"><svg>…</svg></a>
    //          <a href="/mail" aria-label="Mail"><svg>…</svg></a>
    //        </nav>
    // Each child is an icon. Labels come from aria-label, title or data-label.
    // Optional: data-scale (how big the nearest icon gets, default 1.8),
    // data-range (how many icons either side are affected, default 2.5),
    // data-labels="false" (no labels), data-autoplay (a pretend pointer
    // sweeps along the dock while no real one is over it; handy for demos and
    // touch screens).
    // Icons grow upwards, so leave some room above the dock.
    var DEFAULTS = { scale: 1.8, range: 2.5 };

    var css =
      '[data-dock-magnification]{display:flex;align-items:flex-end}' +
      '.dkm-item{position:relative;flex:none}' +
      '.dkm-icon{display:block;margin:0 auto;transform-origin:50% 100%}' +
      '.dkm-tip{position:absolute;left:50%;bottom:100%;translate:-50% 0;margin-bottom:8px;padding:4px 10px;border-radius:7px;' +
      'background:rgba(20,20,28,.85);color:#fff;font:600 12px/1.3 system-ui,sans-serif;white-space:nowrap;pointer-events:none;opacity:0}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function setup(el) {
      var d = el.dataset;
      var max = +d.scale || DEFAULTS.scale;
      var range = +d.range || DEFAULTS.range;
      var items = Array.prototype.slice.call(el.children).map(function (n) {
        n.classList.add('dkm-item');
        // Wrap the contents, so the icon can scale while the slot only widens.
        var icon = document.createElement('span');
        icon.className = 'dkm-icon';
        while (n.firstChild) icon.appendChild(n.firstChild);
        n.appendChild(icon);
        var base = n.offsetWidth;
        n.style.width = icon.style.width = base + 'px';
        n.style.height = icon.style.height = n.offsetHeight + 'px';
        var text = n.getAttribute('aria-label') || n.getAttribute('title') || n.dataset.label;
        var tip = null;
        if (text && d.labels !== 'false') {
          tip = document.createElement('span');
          tip.className = 'dkm-tip';
          tip.textContent = text;
          tip.setAttribute('aria-hidden', 'true');
          n.appendChild(tip);
        }
        return {
          el: n, icon: icon, tip: tip, base: base, s: 1, lit: false,
          slot: anime.createAnimatable(n, { width: 250, ease: 'out(3)' }),
          grow: anime.createAnimatable(icon, { scale: 250, ease: 'out(3)' })
        };
      });
      if (!items.length) return;
      var gap = parseFloat(getComputedStyle(el).columnGap) || 0;

      // Where each icon's centre sits at rest, from the dock's centre.
      function restCentres() {
        var total = 0;
        items.forEach(function (it) { total += it.base; });
        total += gap * (items.length - 1);
        var x = -total / 2;
        items.forEach(function (it) { it.rest = x + it.base / 2; x += it.base + gap; });
      }
      restCentres();

      // Size every icon for a pointer at screen x (null: back to rest).
      function magnify(px) {
        var r = el.getBoundingClientRect();
        var mid = r.left + r.width / 2;
        var unit = items[0].base + gap;
        var nearest = null, best = 0.5;
        items.forEach(function (it) {
          var f = 0;
          if (px !== null) {
            var dist = Math.abs(px - (mid + it.rest)) / (unit * range);
            f = dist < 1 ? (1 + Math.cos(Math.PI * dist)) / 2 : 0;
          }
          it.f = f;
          if (f > best) { best = f; nearest = it; }
        });
        items.forEach(function (it) {
          var f = it.f;
          var s = 1 + (max - 1) * f;
          it.slot.width(it.base * s, px === null ? 600 : 250, px === null ? 'outElastic(1, .6)' : 'out(3)');
          it.grow.scale(s, px === null ? 600 : 250, px === null ? 'outElastic(1, .6)' : 'out(3)');
          var lit = it === nearest; // only one label at a time
          if (it.tip && lit !== it.lit) {
            anime.animate(it.tip, { opacity: lit ? 1 : 0, y: lit ? [6, 0] : 0, duration: 200, ease: 'out(2)' });
          }
          it.lit = lit;
          it.tipLift = it.base * (s - 1);
          if (it.tip) it.tip.style.marginBottom = 8 + it.tipLift + 'px';
        });
      }

      function bounce(it) {
        anime.animate(it.icon, {
          y: [0, -it.base * 0.7, 0, -it.base * 0.3, 0],
          duration: 900,
          ease: 'out(2)'
        });
      }

      var real = false;
      el.addEventListener('pointermove', function (e) {
        if (e.pointerType === 'touch') return;
        real = true;
        if (sweep) sweep.pause();
        magnify(e.clientX);
      });
      el.addEventListener('pointerleave', function (e) {
        if (e.pointerType === 'touch') return;
        real = false;
        magnify(null);
        if (sweep && visible) setTimeout(function () { if (!real && visible) sweep.play(); }, 1000);
      });
      items.forEach(function (it) {
        it.el.addEventListener('click', function () { bounce(it); });
      });

      // A pretend pointer gliding along the dock, with a click halfway.
      var sweep = null, visible = false;
      if (d.autoplay !== undefined && d.autoplay !== 'false') {
        var pos = { p: -0.15 };
        var follow = function () {
          if (real) return;
          var r = el.getBoundingClientRect();
          magnify(r.left + r.width * pos.p);
        };
        var pick = Math.floor(items.length * 0.6);
        sweep = anime.createTimeline({ autoplay: false, loop: true })
          .add(pos, { p: 1.15, duration: 3200, ease: 'inOut(2)', onUpdate: follow })
          .add(pos, { p: (pick + 0.5) / items.length, duration: 1500, ease: 'inOut(3)', onUpdate: follow })
          .call(function () { if (!real) bounce(items[pick]); })
          .add(pos, { p: -0.15, duration: 2400, ease: 'inOut(2)', onUpdate: follow }, '+=900');
        new IntersectionObserver(function (entries) {
          visible = entries[0].isIntersecting;
          if (real) return;
          if (visible) sweep.play(); else sweep.pause();
        }).observe(el);
      }
    }

    function init() {
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
      document.querySelectorAll('[data-dock-magnification]').forEach(function (el) {
        if (el._dkm) return;
        el._dkm = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<nav class="dock" data-dock-magnification>
  <a href="/" aria-label="Home"><img src="home.svg" alt=""></a>
  <a href="/mail" aria-label="Mail"><img src="mail.svg" alt=""></a>
  <a href="/music" aria-label="Music"><img src="music.svg" alt=""></a>
</nav>