Button to Popover Morph
Press the button and it stretches, reshapes and recolours itself into the popover it opens, with the contents drifting in once there’s room. Close it and the popover shrinks back into the button.
Share this page
How to use it
- Paste the snippet just before
</head>. - Add
data-button-to-popover-morphto a wrapper holding a<button>and, right after it, the popover. - Style both however you like: the morph runs between their sizes, corners and background colours. Add
data-popover-closeto anything inside that should close it.
Options
Add any of these attributes to the same element.
data-align | Which part of the button the popover lines up with: left, center or right. Default left. |
data-direction | down or up. Default down. |
data-duration | Time for the morph. Default 520 ms. |
data-autoplay | Time it stays open, in ms: it opens and closes by itself while in view until someone touches it. Handy for demos. |
Esc or a press anywhere else closes it. Focus moves into the popover and back to the button, and the button’s aria-expanded follows along. It never runs off the side of the screen. From code: animacious.popoverMorph(wrapper) toggles it.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Button to Popover Morph: press the button and it stretches, reshapes and
// recolours itself into the popover it opens, whose contents then drift in.
// Closing shrinks the popover back into the button.
// Usage:
// <div data-button-to-popover-morph>
// <button>Share</button>
// <div>
// …popover content…
// <button data-popover-close>Close</button>
// </div>
// </div>
// The first button is the trigger and the next element is the popover;
// style both as you like: the morph runs between their sizes, corners and
// background colours.
// Optional: data-align ("left", "center" or "right": which part of the
// button the popover lines up with, default "left"), data-direction
// ("down", the default, or "up"), data-duration (ms, default 520),
// data-autoplay (ms it stays open; it opens and closes by itself while in
// view until someone touches it, handy for demos).
// Events: "popoveropen" and "popoverclose" on the wrapper.
// Script use: animacious.popoverMorph(wrapper) toggles it, pass true/false to force.
var DEFAULTS = { align: 'left', direction: 'down', duration: 520, autoplay: 2400 };
var css =
'.bpm{position:relative;display:inline-block;vertical-align:top}' +
'.bpm.bpm-on{z-index:30}' +
'.bpm-skin{position:absolute;left:0;top:0;visibility:hidden;pointer-events:none;box-shadow:0 18px 44px rgba(20,12,40,.22)}' +
'.bpm-pop{position:absolute;top:0;left:0;z-index:1;visibility:hidden;box-sizing:border-box}' +
'.bpm-pop.bpm-up{top:auto;bottom:0}' +
':where(.bpm-pop){width:max-content;max-width:calc(100vw - 32px);padding:16px;border-radius:18px;background:#fff;color:#1a1625}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
var uid = 0;
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function on(v) { return v !== undefined && v !== 'false'; }
function setup(el) {
var d = el.dataset;
var btn = el.querySelector('button, [data-popover-trigger]');
var pop = btn && btn.nextElementSibling;
if (!btn || !pop) return null;
var m = {
el: el, btn: btn, pop: pop, open: false,
align: d.align || DEFAULTS.align,
up: (d.direction || DEFAULTS.direction) === 'up',
duration: +d.duration || DEFAULTS.duration,
decorative: !!el.closest('[aria-hidden="true"]')
};
el._bpm = m;
el.classList.add('bpm');
pop.classList.add('bpm-pop');
if (m.up) pop.classList.add('bpm-up');
var skin = document.createElement('span');
skin.className = 'bpm-skin';
skin.setAttribute('aria-hidden', 'true');
el.insertBefore(skin, el.firstChild);
m.skin = skin;
if (!m.decorative) {
if (!pop.id) pop.id = 'bpm-pop-' + ++uid;
btn.setAttribute('aria-expanded', 'false');
btn.setAttribute('aria-controls', pop.id);
if (!pop.getAttribute('role')) pop.setAttribute('role', 'dialog');
if (!pop.hasAttribute('tabindex')) pop.tabIndex = -1;
if (btn.tagName === 'BUTTON' && !btn.getAttribute('type')) btn.type = 'button';
}
btn.addEventListener('click', function () { m.auto = false; toggle(m, !m.open, true); });
pop.addEventListener('click', function (e) { if (e.target.closest('[data-popover-close]')) toggle(m, false, true); });
el.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && m.open) { e.stopPropagation(); toggle(m, false, true); }
});
return m;
}
// Where the popover sits inside the wrapper, kept on screen.
function place(m) {
var pop = m.pop, bw = m.btn.offsetWidth, pw = pop.offsetWidth;
var left = m.align === 'right' ? bw - pw : m.align === 'center' ? (bw - pw) / 2 : 0;
var box = m.el.getBoundingClientRect();
var bounds = m.el.closest('.anim-thumb') ? m.el.closest('.anim-thumb').getBoundingClientRect() : { left: 0, right: document.documentElement.clientWidth };
var min = bounds.left + 8 - box.left, max = bounds.right - 8 - box.left - pw;
left = Math.max(min, Math.min(max, left));
pop.style.left = left + 'px';
return { left: left, top: m.up ? m.btn.offsetHeight - pop.offsetHeight : 0, width: pw, height: pop.offsetHeight };
}
function look(node) {
var cs = getComputedStyle(node);
return { bg: cs.backgroundColor, radius: parseFloat(cs.borderTopLeftRadius) || 0 };
}
function toggle(m, open, byUser) {
if (open === m.open) return;
m.open = open;
var el = m.el, btn = m.btn, pop = m.pop, skin = m.skin;
var kids = Array.from(pop.children);
anime.utils.remove([skin, btn, pop].concat(kids));
var t = m.duration, rm = reduced();
var b = { left: btn.offsetLeft, top: btn.offsetTop, width: btn.offsetWidth, height: btn.offsetHeight };
var bl = look(btn);
if (!m.decorative) btn.setAttribute('aria-expanded', open);
el.dispatchEvent(new CustomEvent(open ? 'popoveropen' : 'popoverclose', { bubbles: true }));
if (open) {
el.classList.add('bpm-on');
pop.style.background = '';
var p = place(m), pl = look(pop);
m.popBg = pl.bg;
pop.style.background = 'transparent';
pop.style.visibility = 'visible';
skin.style.visibility = 'visible';
if (rm) {
anime.utils.set(skin, { left: p.left, top: p.top, width: p.width, height: p.height, borderRadius: pl.radius, backgroundColor: pl.bg });
anime.utils.set(btn, { opacity: 0 });
anime.utils.set(kids, { opacity: 1, y: 0 });
} else {
anime.utils.set(kids, { opacity: 0 });
anime.animate(btn, { opacity: 0, scale: 0.96, duration: 140, ease: 'out(2)' });
anime.animate(skin, {
left: [b.left, p.left], top: [b.top, p.top], width: [b.width, p.width], height: [b.height, p.height],
borderRadius: [bl.radius, pl.radius], backgroundColor: [bl.bg, pl.bg],
duration: t, ease: anime.spring({ bounce: 0.18, duration: t })
});
anime.animate(kids, { opacity: [0, 1], y: [m.up ? -8 : 8, 0], duration: t * 0.7, delay: anime.stagger(40, { start: t * 0.35 }), ease: 'out(3)' });
}
if (byUser && !m.decorative) setTimeout(function () {
if (!m.open) return;
var first = pop.querySelector('a[href],button:not([disabled]),input,select,textarea,[tabindex]:not([tabindex="-1"])');
(first || pop).focus({ preventScroll: true });
}, rm ? 0 : t * 0.4);
return;
}
var back = pop.contains(document.activeElement);
var p2 = { left: parseFloat(skin.style.left) || 0, top: parseFloat(skin.style.top) || 0, width: skin.offsetWidth, height: skin.offsetHeight };
var done = function () {
if (m.open) return;
el.classList.remove('bpm-on');
skin.style.visibility = 'hidden';
pop.style.visibility = 'hidden';
anime.utils.set(btn, { opacity: 1, scale: 1 });
};
if (back && !m.decorative) btn.focus({ preventScroll: true });
if (rm) return done();
anime.animate(kids, { opacity: 0, duration: 140, ease: 'out(2)' });
anime.animate(skin, {
left: [p2.left, b.left], top: [p2.top, b.top], width: [p2.width, b.width], height: [p2.height, b.height],
borderRadius: bl.radius, backgroundColor: [m.popBg, bl.bg],
duration: t * 0.8, delay: 80, ease: 'inOut(3)', onComplete: done
});
anime.animate(btn, { opacity: 1, scale: [0.96, 1], duration: 200, delay: t * 0.8 - 40, ease: 'out(2)' });
}
// Demo mode: open, wait, close, wait, again, while in view.
function autoplay(m, hold) {
var timer = null, visible = false;
function cycle() {
clearTimeout(timer);
if (!visible || !m.auto) return;
timer = setTimeout(function () {
if (!visible || !m.auto) return;
toggle(m, true);
timer = setTimeout(function () {
if (!m.auto) return;
toggle(m, false);
timer = setTimeout(cycle, m.duration);
}, hold);
}, 1000);
}
m.auto = true;
m.el.addEventListener('pointerdown', function (e) { if (e.isTrusted) { m.auto = false; clearTimeout(timer); } });
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (visible && !m.open) cycle();
else clearTimeout(timer);
}, { threshold: 0.4 }).observe(m.el);
}
// A press anywhere else closes open popovers.
document.addEventListener('pointerdown', function (e) {
document.querySelectorAll('.bpm.bpm-on').forEach(function (el) {
if (e.isTrusted && el._bpm && el._bpm.open && !el.contains(e.target)) toggle(el._bpm, false);
});
});
function init() {
document.querySelectorAll('[data-button-to-popover-morph]').forEach(function (el) {
if (el._bpm) return;
var m = setup(el);
if (m && on(el.dataset.autoplay) && !reduced()) autoplay(m, +el.dataset.autoplay || DEFAULTS.autoplay);
});
}
window.animacious = window.animacious || {};
window.animacious.popoverMorph = function (el, open) {
var m = el._bpm || setup(el);
if (m) toggle(m, open === undefined ? !m.open : !!open);
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-button-to-popover-morph data-align="center">
<button>Share</button>
<div class="share-card">
<p>Share this page</p>
<button>Copy link</button>
<button data-popover-close>Close</button>
</div>
</div>