Morphing Dropdown
One dropdown card serves the whole menu. Move from item to item and the card glides across, stretching or shrinking to fit the next panel, while the old contents slide out one way and the new ones slide in from the other. Its little arrow follows along.
How to use it
- Paste the snippet just before
</head>. - Add
data-morphing-dropdownto your nav. Each child is a menu item: its first button or link opens it, and the element inside markeddata-panelis what shows in the card. - Size the panels with CSS (a width and some padding). Items without a panel stay plain links.
Options
Add any of these attributes to the same element.
data-bg | Background of the card. Default #fff. |
data-duration | How long the card takes to morph. Default 420 ms. |
data-autoplay | It opens each item in turn while in view, pausing when someone uses it. Handy for demos. |
With a mouse it opens on hover and closes shortly after the pointer leaves; on touch screens a tap opens and closes it. Escape or a tap elsewhere closes it. The open item gets aria-expanded="true" and the class mdd-active. The card stays inside the nav’s parent, so it never pokes past the edge of a phone screen.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Morphing Dropdown: one dropdown card serves the whole menu. Move between
// the menu items and the card glides across, stretching or shrinking to fit
// the next panel, while the old contents slide out one way and the new ones
// slide in from the other. Its little arrow follows the item too.
// Usage: <nav data-morphing-dropdown>
// <div><button>Products</button><div data-panel>…</div></div>
// <div><button>Company</button><div data-panel>…</div></div>
// <a href="/pricing">Pricing</a>
// </nav>
// Each child is a menu item: its first button or link opens it, and its
// [data-panel] is what shows in the card. Items without a panel are plain links.
// The panels' own CSS sets their size (give them a width); the card fits them.
// Optional: data-bg (card background, default #fff), data-duration (ms for
// the morph, default 420), data-autoplay (it moves through the items by itself
// while in view, pausing when someone uses it; handy for demos).
// Hover opens it with a mouse; tapping or clicking an item opens and closes
// it; Escape closes it. The open item gets aria-expanded="true".
var DEFAULTS = { bg: '#fff', duration: 420, every: 1500, closeDelay: 160, margin: 8 };
var css =
'.mdd{position:relative}' +
'.mdd-trigger{cursor:pointer;-webkit-tap-highlight-color:transparent}' +
'.mdd-box{position:absolute;left:0;top:100%;z-index:30;padding-top:10px;visibility:hidden;perspective:900px;pointer-events:none}' +
'.mdd-on .mdd-box{pointer-events:auto}' +
'.mdd-card{position:relative;transform-origin:50% 0;will-change:transform}' +
'.mdd-bg{position:absolute;left:0;top:0;border-radius:14px;box-shadow:0 18px 44px rgba(30,15,60,.16),0 2px 6px rgba(30,15,60,.06)}' +
'.mdd-arrow{position:absolute;top:-5px;left:0;width:12px;height:12px;margin-left:-6px;border-radius:3px 0 0 0;rotate:45deg}' +
'.mdd-clip{position:absolute;left:0;top:0;overflow:hidden;border-radius:14px}' +
'.mdd-panel{position:absolute;left:0;top:0;width:max-content;box-sizing:border-box;visibility:hidden}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
// Panels may be no wider than the space the menu has.
function measure(s) {
var room = s.el.parentElement ? s.el.parentElement.clientWidth : s.el.clientWidth;
s.items.forEach(function (it) {
if (!it.panel) return;
it.panel.style.maxWidth = Math.max(160, room - 2 * DEFAULTS.margin) + 'px';
it.w = it.panel.offsetWidth;
it.h = it.panel.offsetHeight;
});
}
// Where the card sits for item i: centred under the item, kept inside the
// menu's parent so it never pokes past the edge.
function spot(s, i) {
var it = s.items[i];
var parent = s.el.parentElement || s.el;
var er = s.el.getBoundingClientRect(), pr = parent.getBoundingClientRect(), tr = it.trigger.getBoundingClientRect();
var mid = tr.left + tr.width / 2 - er.left;
var lo = pr.left - er.left + DEFAULTS.margin, hi = pr.right - er.left - DEFAULTS.margin - it.w;
var x = Math.max(lo, Math.min(hi, mid - it.w / 2));
if (hi < lo) x = (lo + hi) / 2;
return { x: x, w: it.w, h: it.h, a: mid - x };
}
function draw(s) {
var g = s.geo;
s.card.style.transform = 'translateX(' + g.x + 'px)';
s.bg.style.width = s.clip.style.width = g.w + 'px';
s.bg.style.height = s.clip.style.height = g.h + 'px';
s.arrow.style.left = g.a + 'px';
}
function show(el, i) {
var s = el._mdd;
if (i === s.index) return;
var still = reduced();
var prev = s.index;
var it = s.items[i];
s.index = i;
el.classList.add('mdd-on');
s.items.forEach(function (o, k) {
if (o.panel && s.a11y) o.trigger.setAttribute('aria-expanded', k === i ? 'true' : 'false');
o.trigger.classList.toggle('mdd-active', k === i);
});
measure(s);
var to = spot(s, i);
if (s.morph) s.morph.pause();
anime.utils.remove(s.card);
s.box.style.visibility = 'visible';
if (prev < 0) {
// Opening from closed: the card appears in place and swings down.
s.geo.x = to.x; s.geo.w = to.w; s.geo.h = to.h; s.geo.a = to.a;
draw(s);
s.items.forEach(function (o) {
if (!o.panel) return;
anime.utils.remove(o.panel);
anime.utils.set(o.panel, { x: 0, opacity: o === it ? 1 : 0 });
o.panel.style.visibility = o === it ? 'visible' : 'hidden';
});
if (still) anime.utils.set(s.card, { opacity: 1, rotateX: 0, y: 0 });
else anime.animate(s.card, { opacity: [0, 1], rotateX: [-14, 0], y: [-6, 0], scale: [0.97, 1], duration: 300, ease: 'out(3)' });
return;
}
anime.utils.set(s.card, { opacity: 1, rotateX: 0, y: 0, scale: 1 });
var old = s.items[prev].panel;
var dir = i > prev ? 1 : -1;
if (still) {
s.geo.x = to.x; s.geo.w = to.w; s.geo.h = to.h; s.geo.a = to.a;
draw(s);
old.style.visibility = 'hidden';
anime.utils.set(it.panel, { x: 0, opacity: 1 });
it.panel.style.visibility = 'visible';
return;
}
s.morph = anime.animate(s.geo, {
x: to.x, w: to.w, h: to.h, a: to.a,
duration: s.duration,
ease: 'out(4)',
onRender: function () { draw(s); }
});
anime.utils.remove([old, it.panel]);
anime.animate(old, {
x: -60 * dir, opacity: 0,
duration: s.duration * 0.6,
ease: 'out(3)',
onComplete: function () { if (s.items[s.index].panel !== old) old.style.visibility = 'hidden'; }
});
it.panel.style.visibility = 'visible';
anime.animate(it.panel, { x: [60 * dir, 0], opacity: [0, 1], duration: s.duration, delay: 40, ease: 'out(4)' });
}
function hide(el) {
var s = el._mdd;
if (s.index < 0) return;
var i = s.index;
s.index = -1;
el.classList.remove('mdd-on');
s.items.forEach(function (o) {
if (o.panel && s.a11y) o.trigger.setAttribute('aria-expanded', 'false');
o.trigger.classList.remove('mdd-active');
});
if (s.morph) s.morph.pause();
var done = function () {
if (s.index >= 0) return;
s.box.style.visibility = 'hidden';
s.items[i].panel.style.visibility = 'hidden';
};
if (reduced()) { done(); return; }
anime.animate(s.card, { opacity: 0, rotateX: -10, y: -4, scale: 0.98, duration: 220, ease: 'in(2)', onComplete: done });
}
function setup(el) {
var d = el.dataset;
var a11y = !el.closest('[aria-hidden="true"]');
var bgColor = d.bg || DEFAULTS.bg;
el.classList.add('mdd');
var box = document.createElement('div');
box.className = 'mdd-box';
var card = document.createElement('div');
card.className = 'mdd-card';
var bg = document.createElement('div');
bg.className = 'mdd-bg';
bg.style.background = bgColor;
var arrow = document.createElement('span');
arrow.className = 'mdd-arrow';
arrow.style.background = bgColor;
arrow.setAttribute('aria-hidden', 'true');
var clip = document.createElement('div');
clip.className = 'mdd-clip';
card.appendChild(bg);
card.appendChild(arrow);
card.appendChild(clip);
box.appendChild(card);
var items = [];
var uid = 'mdd-' + Math.random().toString(36).slice(2, 8);
Array.prototype.slice.call(el.children).forEach(function (child) {
var trigger = child.matches('a,button,[data-trigger]') ? child : child.querySelector('[data-trigger],button,a') || child;
var panel = child.querySelector('[data-panel]');
var it = { trigger: trigger, panel: panel, w: 0, h: 0 };
trigger.classList.add('mdd-trigger');
if (panel) {
panel.classList.add('mdd-panel');
clip.appendChild(panel);
if (a11y) {
panel.id = panel.id || uid + '-' + items.length;
trigger.setAttribute('aria-expanded', 'false');
trigger.setAttribute('aria-controls', panel.id);
if (trigger.tagName === 'BUTTON' && !trigger.getAttribute('type')) trigger.type = 'button';
}
}
items.push(it);
});
el.appendChild(box);
if (!a11y) box.setAttribute('aria-hidden', 'true');
var s = el._mdd = {
el: el, a11y: a11y, box: box, card: card, bg: bg, arrow: arrow, clip: clip, items: items,
index: -1, geo: { x: 0, w: 0, h: 0, a: 0 }, morph: null,
duration: +d.duration || DEFAULTS.duration
};
anime.utils.set(card, { opacity: 0 });
var fine = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
var closeTimer = null, idleUntil = 0;
var later = function () { clearTimeout(closeTimer); closeTimer = setTimeout(function () { hide(el); }, DEFAULTS.closeDelay); };
var stay = function () { clearTimeout(closeTimer); };
items.forEach(function (it, k) {
it.trigger.addEventListener('pointerenter', function (e) {
if (e.pointerType !== 'mouse' || !fine) return;
idleUntil = Date.now() + 6000;
stay();
if (it.panel) show(el, k); else later();
});
if (!it.panel) return;
it.trigger.addEventListener('click', function (e) {
e.preventDefault();
idleUntil = Date.now() + 8000;
stay();
// With a mouse, hover already opened it; a click keeps it open.
if (s.index === k && !(fine && e.pointerType === 'mouse')) hide(el); else show(el, k);
});
});
el.addEventListener('pointerleave', function (e) { if (e.pointerType === 'mouse') later(); });
box.addEventListener('pointerenter', stay);
el.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && s.index >= 0) {
var t = s.items[s.index].trigger;
hide(el);
if (a11y) t.focus();
}
});
el.addEventListener('focusout', function (e) {
if (!el.contains(e.relatedTarget)) later();
});
document.addEventListener('pointerdown', function (e) {
if (s.index >= 0 && !el.contains(e.target)) hide(el);
});
if (window.ResizeObserver) {
new ResizeObserver(function () {
if (s.index < 0) return;
measure(s);
var to = spot(s, s.index);
s.geo.x = to.x; s.geo.w = to.w; s.geo.h = to.h; s.geo.a = to.a;
draw(s);
}).observe(el.parentElement || el);
}
if (d.autoplay !== undefined && d.autoplay !== 'false' && !reduced()) {
var timer = null, step = 0;
var withPanels = [];
items.forEach(function (it, k) { if (it.panel) withPanels.push(k); });
var tick = function () {
var wait = DEFAULTS.every;
if (Date.now() > idleUntil && !(fine && el.matches(':hover')) && !el.contains(document.activeElement)) {
// Open each item in turn, then close for a beat.
if (step < withPanels.length) show(el, withPanels[step]);
else { hide(el); wait = 900; }
step = (step + 1) % (withPanels.length + 1);
}
timer = setTimeout(tick, wait);
};
new IntersectionObserver(function (entries) {
clearTimeout(timer);
if (entries[0].isIntersecting) timer = setTimeout(tick, 600);
}).observe(el);
}
}
function init() {
document.querySelectorAll('[data-morphing-dropdown]').forEach(function (el) {
if (el._mdd) return;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><nav data-morphing-dropdown>
<div>
<button>Products</button>
<div data-panel style="width:320px">…</div>
</div>
<div>
<button>Company</button>
<div data-panel style="width:220px">…</div>
</div>
<a href="/pricing">Pricing</a>
</nav>