← Navigation

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

  1. Paste the snippet just before </head>.
  2. Add data-full-screen-menu to your header, with your links in a <nav> inside it.
  3. 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-bgBackground of the overlay, any CSS background. Default a purple gradient.
data-colorColour of the links and the close icon. Default #fff.
data-containFill the nearest positioned parent instead of the whole window. Handy for previews like this one.
data-autoplayIt 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.

1 · Snippet
<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>
2 · Your HTML
<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>