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
- Paste the snippet just before
</head>. - Add
data-dock-magnificationto a row of icon links. Labels come from each one’saria-label,titleordata-label.
Options
Add any of these attributes to the same element.
data-scale | How big the nearest icon gets. Default 1.8. |
data-range | How many icons either side of the pointer grow too. Default 2.5. |
data-labels="false" | No labels. |
data-autoplay | A 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.
<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><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>