Gooey Nav
Pick a link and the pill behind the current one melts into goo. Droplets stream across the bar, stretching and merging like liquid, and swell into a fresh pill under the link you chose.
How to use it
- Paste the snippet just before
</head>. - Add
data-gooey-navto your nav. Each child is a menu item. - Give the items some padding so the pill has room round the label.
Options
Add any of these attributes to the same element.
data-color | Colour of the goo. Default #6a0dad. |
data-active | Index of the item selected at first, counting from 0. Default 0. |
data-drops | How many droplets flow across. Default 5. |
data-duration | How long the flow takes. Default 700 ms. |
data-autoplay | It moves to the next item every couple of seconds while in view, pausing when someone clicks. Handy for demos. |
The goo is an SVG blur-and-sharpen filter on a layer behind the labels, so the text stays crisp. The active item gets aria-current="page" and the class gnv-on, and a navchange event fires. Links with a real href still navigate.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Gooey Nav: the pill behind the active link melts into goo when you pick
// another one. Droplets stream across, stretching and merging like liquid,
// and swell into a new pill under the link you chose.
// Usage: <nav data-gooey-nav><a href="#">Home</a><a href="#">Work</a>…</nav>
// Every child element is a menu item. The active one gets class "gnv-on"
// and aria-current="page"; a "navchange" event (e.detail.index) fires on a change.
// Optional: data-color (goo colour, default #6a0dad), data-active (index
// selected at first, default 0), data-drops (droplets per move, default 5),
// data-duration (ms, default 700), data-autoplay (moves to the next item every
// couple of seconds while in view; it pauses for a while when someone clicks).
// Script use: animacious.gooeyNav(nav, index) selects an item from code.
var DEFAULTS = { color: '#6a0dad', active: 0, drops: 5, duration: 700, every: 2000 };
var uid = 0;
var css =
'.gnv{position:relative;display:inline-flex;align-items:center;isolation:isolate}' +
'.gnv-item{position:relative;z-index:1;cursor:pointer;-webkit-tap-highlight-color:transparent;transition:color .25s}' +
'.gnv-item.gnv-on{color:#fff}' +
'.gnv-goo{position:absolute;inset:0;z-index:0;pointer-events:none}' +
'.gnv-blob{position:absolute;left:0;top:0;border-radius:999px;will-change:transform}' +
'.gnv-svg{position:absolute;width:0;height:0;overflow:hidden}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function box(t) { return { x: t.offsetLeft, y: t.offsetTop, w: t.offsetWidth, h: t.offsetHeight }; }
function placePill(p, b) {
p.style.width = b.w + 'px';
p.style.height = b.h + 'px';
anime.utils.set(p, { x: b.x, y: b.y });
}
function mark(s, i) {
s.items.forEach(function (t, k) {
t.classList.toggle('gnv-on', k === i);
if (s.a11y) { if (k === i) t.setAttribute('aria-current', 'page'); else t.removeAttribute('aria-current'); }
});
}
function select(el, i, instant) {
var s = el._gnv;
i = (i + s.items.length) % s.items.length;
var from = s.index;
s.index = i;
var to = box(s.items[i]);
var cur = s.pills[s.which];
if (instant || from < 0 || reduced() || from === i) {
anime.utils.remove(s.pills.concat(s.drops));
s.pills.forEach(function (p) { anime.utils.set(p, { scale: 0 }); });
s.drops.forEach(function (d) { anime.utils.set(d, { opacity: 0 }); });
placePill(cur, to);
anime.utils.set(cur, { scale: 1 });
mark(s, i);
return;
}
var old = box(s.items[from]);
var next = s.pills[s.which = 1 - s.which];
var dur = s.duration;
anime.utils.remove(s.pills.concat(s.drops));
mark(s, -1);
// The old pill melts away where it stands.
anime.animate(cur, { scale: 0, duration: dur * 0.75, ease: 'in(2)' });
// Droplets run from one item to the other, beading and merging.
var size = Math.round(old.h * 0.62);
var y = old.y + old.h / 2 - size / 2;
var ax = old.x + old.w / 2 - size / 2, bx = to.x + to.w / 2 - size / 2;
var bend = (to.x > old.x ? 1 : -1) * Math.min(10, old.h * 0.28);
s.drops.forEach(function (d, k) {
d.style.width = d.style.height = size + 'px';
var f = 1 - k / s.drops.length;
anime.utils.set(d, { x: ax, y: y, scale: 0.6 + 0.4 * f, opacity: 1 });
anime.animate(d, {
x: bx,
y: [y, y + (k % 2 ? bend : -bend), y],
scale: [0.6 + 0.4 * f, 0.9, 0.3],
duration: dur,
delay: k * dur * 0.07,
ease: 'inOut(3)',
onComplete: function () { anime.utils.set(d, { opacity: 0 }); }
});
});
// A new pill swells up under the chosen item as the goo arrives.
placePill(next, to);
anime.utils.set(next, { scale: 0 });
anime.animate(next, { scale: [0, 1], duration: dur * 1.1, delay: dur * 0.45, ease: 'outElastic(1, .7)' });
clearTimeout(s.labelTimer);
s.labelTimer = setTimeout(function () { mark(s, s.index); }, dur * 0.6);
el.dispatchEvent(new CustomEvent('navchange', { bubbles: true, detail: { index: i } }));
}
function setup(el) {
var d = el.dataset;
var items = Array.prototype.slice.call(el.children);
if (!items.length) return;
var color = d.color || DEFAULTS.color;
var id = 'gooey-nav-goo-' + (++uid);
el.classList.add('gnv');
// The goo: everything in this layer is blurred, then the alpha is
// sharpened again, so shapes that touch flow into one another.
var svgNS = 'http://www.w3.org/2000/svg';
var svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('class', 'gnv-svg');
svg.setAttribute('aria-hidden', 'true');
svg.innerHTML = '<filter id="' + id + '" x="-30%" y="-60%" width="160%" height="220%">' +
'<feGaussianBlur in="SourceGraphic" stdDeviation="6" result="b"/>' +
'<feColorMatrix in="b" mode="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 20 -9"/></filter>';
var goo = document.createElement('span');
goo.className = 'gnv-goo';
goo.setAttribute('aria-hidden', 'true');
goo.style.filter = 'url(#' + id + ')';
function blob() {
var b = document.createElement('span');
b.className = 'gnv-blob';
b.style.background = color;
goo.appendChild(b);
return b;
}
var pills = [blob(), blob()];
var drops = [];
var n = Math.max(1, +d.drops || DEFAULTS.drops);
for (var k = 0; k < n; k++) drops.push(blob());
el.appendChild(svg);
el.appendChild(goo);
var a11y = !el.closest('[aria-hidden="true"]');
items.forEach(function (t, k) {
t.classList.add('gnv-item');
if (a11y && t.tagName === 'BUTTON' && !t.getAttribute('type')) t.type = 'button';
t.addEventListener('click', function (e) {
if (t.tagName === 'A' && t.getAttribute('href') === '#') e.preventDefault();
select(el, k);
});
});
var s = el._gnv = {
items: items, pills: pills, drops: drops, which: 0, index: -1, a11y: a11y,
duration: +d.duration || DEFAULTS.duration, labelTimer: null
};
select(el, +d.active || DEFAULTS.active, true);
if (window.ResizeObserver) new ResizeObserver(function () { select(el, s.index, true); }).observe(el);
if (d.autoplay !== undefined && d.autoplay !== 'false' && !reduced()) {
var timer = null, idleUntil = 0, canHover = window.matchMedia('(hover: hover)').matches;
var hold = function () { idleUntil = Date.now() + 8000; };
el.addEventListener('pointerdown', hold);
el.addEventListener('keydown', hold);
var tick = function () {
if (Date.now() > idleUntil && !(canHover && el.matches(':hover'))) select(el, s.index + 1);
timer = setTimeout(tick, DEFAULTS.every);
};
new IntersectionObserver(function (entries) {
clearTimeout(timer);
if (entries[0].isIntersecting) timer = setTimeout(tick, 900);
}).observe(el);
}
}
function init() {
document.querySelectorAll('[data-gooey-nav]').forEach(function (el) {
if (el._gnv) return;
setup(el);
});
}
window.animacious = window.animacious || {};
window.animacious.gooeyNav = function (el, i) { if (el._gnv) select(el, i); };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><nav data-gooey-nav>
<a href="#">Home</a>
<a href="#">Work</a>
<a href="#">Blog</a>
<a href="#">Contact</a>
</nav>