Full-screen Menu
Tap the hamburger and the menu grows out of it as a circle that spreads to fill the screen, while the burger turns into a close icon. Then the links cascade in one after another. Close it and the links lift away as the circle shrinks back into the button.
How to use it
- Paste the snippet just before
</head>. - Add
data-full-screen-menuto your header, with your links in a<nav>inside it. - The script adds the menu button at the end of the header and moves the nav into the overlay.
Options
Add any of these attributes to the same element.
data-bg | Background of the overlay, any CSS background. Default a purple gradient. |
data-color | Colour of the links and the close icon. Default #fff. |
data-contain | Fill the nearest positioned parent instead of the whole window. Handy for previews like this one. |
data-autoplay | It opens and closes itself while in view. Handy for demos. |
Escape or picking a link closes it, the page stops scrolling while it is open, and focus moves to the first link. Style the links with .fsm-panel nav a. Don’t give the header a transform or backdrop-filter, or the overlay will be trapped inside it.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Full-screen Menu: tap the hamburger and the menu grows out of it as a
// circle that spreads to fill the screen, while the burger turns into a
// close icon. Then the links cascade in, one after another. Close it and
// the links lift away and the circle shrinks back into the button.
// Usage: <header data-full-screen-menu>
// <a href="/">Logo</a>
// <nav><a href="/">Home</a><a href="/work">Work</a>…</nav>
// </header>
// The script adds the menu button, and moves the nav into the overlay.
// Optional: data-bg (overlay background, default a purple gradient),
// data-color (link colour, default #fff), data-contain (fill the nearest
// positioned parent instead of the whole window: for demos and previews),
// data-autoplay (it opens and closes itself while in view; handy for demos).
// Escape or picking a link closes it. Listen for the "toggle" event:
// e.detail.open is true or false.
// Script use: animacious.fullScreenMenu(header) opens or closes it.
var DEFAULTS = { bg: 'linear-gradient(135deg,#7c3aed,#4c1d95)', color: '#fff', open: 650, close: 520, stagger: 70 };
var uid = 0;
var css =
'.fsm-btn{position:relative;z-index:51;display:inline-grid;place-items:center;width:2em;height:2em;padding:0;border:0;border-radius:50%;background:none;color:inherit;font-size:20px;cursor:pointer;-webkit-tap-highlight-color:transparent;transition:color .3s}' +
'.fsm-open .fsm-btn{color:var(--fsm-color,#fff)}' +
'.fsm-bar{position:absolute;left:50%;top:50%;width:1.1em;height:.11em;margin:-.055em 0 0 -.55em;border-radius:.06em;background:currentColor;pointer-events:none}' +
'.fsm-panel{position:fixed;inset:0;z-index:50;display:grid;place-items:center;visibility:hidden;clip-path:circle(0px at 50% 50%)}' +
'.fsm-contain .fsm-panel{position:absolute}' +
'.fsm-panel nav{display:flex;flex-direction:column;align-items:center;gap:.25em}' +
'.fsm-panel nav>*{display:block;color:inherit;text-decoration:none;font-weight:800;letter-spacing:-.03em;line-height:1.15}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function clip(s) { s.panel.style.clipPath = 'circle(' + s.c.r + 'px at ' + s.c.x + 'px ' + s.c.y + 'px)'; }
// Where the burger sits inside the overlay, and how far to the farthest corner.
function measure(s) {
var p = s.panel.getBoundingClientRect(), b = s.btn.getBoundingClientRect();
s.c.x = b.left + b.width / 2 - p.left;
s.c.y = b.top + b.height / 2 - p.top;
s.full = Math.max(
Math.hypot(s.c.x, s.c.y), Math.hypot(p.width - s.c.x, s.c.y),
Math.hypot(s.c.x, p.height - s.c.y), Math.hypot(p.width - s.c.x, p.height - s.c.y)
) + 2;
}
function burger(s, open, still) {
var top = s.bars[0], mid = s.bars[1], bot = s.bars[2];
var to = open ? [{ y: '0em', rotate: 45 }, { scaleX: 0, opacity: 0 }, { y: '0em', rotate: -45 }]
: [{ y: '-.32em', rotate: 0 }, { scaleX: 1, opacity: 1 }, { y: '.32em', rotate: 0 }];
[top, mid, bot].forEach(function (b, k) {
if (still) anime.utils.set(b, to[k]);
else anime.animate(b, Object.assign({ duration: 420, ease: 'inOut(3)' }, to[k]));
});
}
function toggle(el, user) {
var s = el._fsm;
var open = (s.open = !s.open);
el.classList.toggle('fsm-open', open);
s.btn.setAttribute('aria-expanded', open);
s.btn.setAttribute('aria-label', open ? 'Close menu' : 'Menu');
el.dispatchEvent(new CustomEvent('toggle', { bubbles: true, detail: { open: open } }));
if (!s.contain) document.documentElement.style.overflow = open ? 'hidden' : '';
var still = reduced();
anime.utils.remove([s.c].concat(s.links));
measure(s);
burger(s, open, still);
if (open) {
s.panel.style.visibility = 'visible';
if (s.a11y) s.panel.removeAttribute('inert');
if (still) { s.c.r = s.full; clip(s); anime.utils.set(s.links, { opacity: 1, y: 0 }); }
else {
anime.animate(s.c, { r: s.full, duration: DEFAULTS.open, ease: 'inOut(3)', onRender: function () { clip(s); } });
anime.animate(s.links, { opacity: [0, 1], y: [40, 0], duration: 650, delay: anime.stagger(DEFAULTS.stagger, { start: DEFAULTS.open * 0.5 }), ease: 'out(4)' });
}
if (user && s.links[0] && s.links[0].focus) setTimeout(function () { s.links[0].focus({ preventScroll: true }); }, still ? 0 : DEFAULTS.open);
} else {
var hide = function () { if (!s.open) { s.panel.style.visibility = 'hidden'; if (s.a11y) s.panel.setAttribute('inert', ''); } };
if (still) { s.c.r = 0; clip(s); hide(); }
else {
anime.animate(s.links, { opacity: 0, y: -24, duration: 260, delay: anime.stagger(35, { from: 'last' }), ease: 'in(2)' });
anime.animate(s.c, { r: 0, duration: DEFAULTS.close, delay: 180, ease: 'inOut(3)', onRender: function () { clip(s); }, onComplete: hide });
}
}
}
function setup(el) {
var d = el.dataset;
var nav = el.querySelector('nav') || el.querySelector('[data-menu]');
if (!nav) return;
var a11y = !el.closest('[aria-hidden="true"]');
var contain = d.contain !== undefined && d.contain !== 'false';
el.classList.add('fsm');
if (contain) el.classList.add('fsm-contain');
var color = d.color || DEFAULTS.color;
el.style.setProperty('--fsm-color', color);
var panel = document.createElement('div');
panel.className = 'fsm-panel';
panel.id = 'full-screen-menu-' + (++uid);
panel.style.background = d.bg || DEFAULTS.bg;
panel.style.color = color;
if (a11y) panel.setAttribute('inert', '');
panel.appendChild(nav);
var btn = document.createElement(a11y ? 'button' : 'span');
btn.className = 'fsm-btn';
if (a11y) {
btn.type = 'button';
btn.setAttribute('aria-label', 'Menu');
btn.setAttribute('aria-expanded', 'false');
btn.setAttribute('aria-controls', panel.id);
}
var bars = [];
for (var k = 0; k < 3; k++) {
var bar = document.createElement('span');
bar.className = 'fsm-bar';
bar.setAttribute('aria-hidden', 'true');
btn.appendChild(bar);
bars.push(bar);
}
el.appendChild(btn);
// Inside a contained demo the overlay sits beside the header in the
// positioned parent; otherwise it lives at the end of the header.
el.appendChild(panel);
var s = el._fsm = {
btn: btn, bars: bars, panel: panel, links: Array.prototype.slice.call(nav.children),
c: { x: 0, y: 0, r: 0 }, full: 0, open: false, contain: contain, a11y: a11y
};
anime.utils.set(s.links, { opacity: 0 });
burger(s, false, true);
btn.addEventListener('click', function () { toggle(el, true); });
nav.addEventListener('click', function (e) {
if (!s.open) return;
var a = e.target.closest('a');
if (a && a.getAttribute('href') === '#') e.preventDefault();
if (a) toggle(el);
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && s.open) { toggle(el); btn.focus(); }
});
// Keep an open menu covering everything when the window changes size.
window.addEventListener('resize', function () {
if (!s.open) return;
anime.utils.remove(s.c);
measure(s);
s.c.r = s.full;
clip(s);
});
if (d.autoplay !== undefined && d.autoplay !== 'false' && !reduced()) {
var timer = null, idleUntil = 0;
el.addEventListener('pointerdown', function () { idleUntil = Date.now() + 8000; });
var tick = function () {
if (Date.now() > idleUntil && el.contains(document.activeElement) === false) toggle(el);
timer = setTimeout(tick, s.open ? 2200 : 1500);
};
new IntersectionObserver(function (entries) {
clearTimeout(timer);
if (entries[0].isIntersecting) timer = setTimeout(tick, 900);
}).observe(el);
}
}
function init() {
document.querySelectorAll('[data-full-screen-menu]').forEach(function (el) {
if (el._fsm) return;
setup(el);
});
}
window.animacious = window.animacious || {};
window.animacious.fullScreenMenu = function (el) { if (el._fsm) toggle(el); };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><header data-full-screen-menu>
<a href="/">Logo</a>
<nav>
<a href="/">Home</a>
<a href="/work">Work</a>
<a href="/about">About</a>
<a href="/contact">Contact</a>
</nav>
</header>