← Navigation

Layered Menu Slide

Tap the menu button and coloured layers sweep in from the side one after another, each hard on the heels of the last, until the final layer carries the menu and its links slide in. Closing peels the layers back in reverse.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-layered-menu-slide 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 onto the last layer.

Options

Add any of these attributes to the same element.

data-colorsComma-separated layer colours; the last one is the menu itself. Default #f9a8d4,#a855f7,#3b0764.
data-fromright, left or top. Default right.
data-widthHow much of the screen the menu covers, any CSS width. Default 100%.
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 .lms-menu nav a.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Layered Menu Slide: tap the menu button and coloured layers sweep in
    // from the side one after another, each hard on the heels of the last,
    // until the final layer carries the menu. Then the links slide in.
    // Closing lifts the links away and peels the layers back in reverse.
    // Usage: <header data-layered-menu-slide>
    //          <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 onto the last layer.
    // Optional: data-colors (comma-separated layer colours, the last one is the
    // menu itself; default #f9a8d4,#a855f7,#3b0764), data-from (right, left or
    // top; default right), data-width (how much of the screen the menu covers,
    // any CSS width; default 100%), data-color (link colour, default #fff),
    // data-contain (fill the nearest positioned parent instead of the 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.layeredMenu(header) opens or closes it.
    var DEFAULTS = { colors: '#f9a8d4,#a855f7,#3b0764', from: 'right', width: '100%', color: '#fff', slide: 620, stagger: 110 };
    var uid = 0;

    var css =
      '.lms-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 .15s}' +
      '.lms-open .lms-btn{color:var(--lms-color,#fff)}' +
      '.lms-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}' +
      '.lms-wrap{position:fixed;top:0;bottom:0;z-index:50;overflow:hidden;visibility:hidden}' +
      '.lms-contain .lms-wrap{position:absolute}' +
      '.lms-layer{position:absolute;inset:0}' +
      '.lms-menu{display:grid;place-items:center}' +
      '.lms-menu nav{display:flex;flex-direction:column;align-items:center;gap:.25em}' +
      '.lms-menu 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; }

    // The off-screen spot each layer waits at.
    function away(s) { return s.from === 'top' ? { x: '0%', y: '-101%' } : { x: s.from === 'left' ? '-101%' : '101%', y: '0%' }; }

    function burger(s, open, still) {
      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 }];
      s.bars.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._lms;
      var open = (s.open = !s.open);
      el.classList.toggle('lms-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();
      var off = away(s);
      var dx = s.from === 'left' ? -30 : s.from === 'top' ? 0 : 30;
      var dy = s.from === 'top' ? -30 : 0;
      anime.utils.remove(s.layers.concat(s.links));
      burger(s, open, still);
      if (open) {
        s.wrap.style.visibility = 'visible';
        if (s.a11y) s.wrap.removeAttribute('inert');
        if (still) {
          anime.utils.set(s.layers, { x: '0%', y: '0%' });
          anime.utils.set(s.links, { opacity: 1, x: 0, y: 0 });
        } else {
          anime.animate(s.layers, { x: '0%', y: '0%', duration: DEFAULTS.slide, delay: anime.stagger(DEFAULTS.stagger), ease: 'inOut(4)' });
          var arrive = DEFAULTS.stagger * (s.layers.length - 1) + DEFAULTS.slide * 0.7;
          anime.animate(s.links, { opacity: [0, 1], x: [dx, 0], y: [dy, 0], duration: 600, delay: anime.stagger(60, { start: arrive }), ease: 'out(4)' });
        }
        if (user && s.links[0] && s.links[0].focus) setTimeout(function () { s.links[0].focus({ preventScroll: true }); }, still ? 0 : 700);
      } else {
        var hide = function () { if (!s.open) { s.wrap.style.visibility = 'hidden'; if (s.a11y) s.wrap.setAttribute('inert', ''); } };
        if (still) {
          anime.utils.set(s.layers, off);
          anime.utils.set(s.links, { opacity: 0 });
          hide();
        } else {
          anime.animate(s.links, { opacity: 0, x: dx * 0.6, y: dy * 0.6, duration: 240, delay: anime.stagger(30, { from: 'last' }), ease: 'in(2)' });
          anime.animate(s.layers, Object.assign({
            duration: DEFAULTS.slide * 0.85, delay: anime.stagger(DEFAULTS.stagger * 0.8, { from: 'last', start: 200 }), ease: 'inOut(4)',
            onComplete: hide
          }, off));
        }
      }
    }

    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';
      var from = d.from || DEFAULTS.from;
      var color = d.color || DEFAULTS.color;
      el.classList.add('lms');
      if (contain) el.classList.add('lms-contain');
      el.style.setProperty('--lms-color', color);

      var wrap = document.createElement('div');
      wrap.className = 'lms-wrap';
      wrap.id = 'layered-menu-slide-' + (++uid);
      var width = d.width || DEFAULTS.width;
      if (from === 'top') { wrap.style.left = '0'; wrap.style.right = '0'; }
      else { wrap.style.width = width; wrap.style[from === 'left' ? 'left' : 'right'] = '0'; }
      if (a11y) wrap.setAttribute('inert', '');
      var colors = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return c.trim(); }).filter(Boolean);
      var layers = colors.map(function (c, k) {
        var layer = document.createElement('div');
        layer.className = 'lms-layer';
        layer.style.background = c;
        if (k < colors.length - 1) layer.setAttribute('aria-hidden', 'true');
        wrap.appendChild(layer);
        return layer;
      });
      var menu = layers[layers.length - 1];
      menu.classList.add('lms-menu');
      menu.style.color = color;
      menu.appendChild(nav);

      var btn = document.createElement(a11y ? 'button' : 'span');
      btn.className = 'lms-btn';
      if (a11y) {
        btn.type = 'button';
        btn.setAttribute('aria-label', 'Menu');
        btn.setAttribute('aria-expanded', 'false');
        btn.setAttribute('aria-controls', wrap.id);
      }
      var bars = [];
      for (var k = 0; k < 3; k++) {
        var bar = document.createElement('span');
        bar.className = 'lms-bar';
        bar.setAttribute('aria-hidden', 'true');
        btn.appendChild(bar);
        bars.push(bar);
      }
      el.appendChild(btn);
      el.appendChild(wrap);

      var s = el._lms = {
        btn: btn, bars: bars, wrap: wrap, layers: layers, links: Array.prototype.slice.call(nav.children),
        from: from, open: false, contain: contain, a11y: a11y
      };
      anime.utils.set(layers, away(s));
      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(); }
      });

      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)) toggle(el);
          timer = setTimeout(tick, s.open ? 2400 : 1500);
        };
        new IntersectionObserver(function (entries) {
          clearTimeout(timer);
          if (entries[0].isIntersecting) timer = setTimeout(tick, 900);
        }).observe(el);
      }
    }

    function init() {
      document.querySelectorAll('[data-layered-menu-slide]').forEach(function (el) {
        if (el._lms) return;
        setup(el);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.layeredMenu = function (el) { if (el._lms) toggle(el); };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<header data-layered-menu-slide>
  <a href="/">Logo</a>
  <nav>
    <a href="/">Home</a>
    <a href="/work">Work</a>
    <a href="/contact">Contact</a>
  </nav>
</header>

<!-- a side drawer in blues: -->
<header data-layered-menu-slide data-from="left" data-width="min(420px, 100%)" data-colors="#bae6fd,#38bdf8,#0c4a6e">…</header>