โ† Overlays

Side Drawer

A panel slides in from the side as the page behind it dims, and its contents follow it in one after another. Esc, a tap on the dimmed page or a swipe back towards the edge sends it away again.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-side-drawer to the panel (an <aside> or <nav>) and give it an id.
  3. Add data-drawer-open="#that-id" to the button that opens it, and data-drawer-close to anything inside that should close it.

Options

Add any of these attributes to the same element.

data-sideleft or right. Default left.
data-widthWidth in px; it never takes more than 85% of the screen. Default 320.
data-durationTime to slide in. Default 480 ms.
data-containedSlide in over its parent instead of the whole page. Give the parent position: relative and overflow: hidden.
data-autoplayTime it stays open, in ms: it opens and closes by itself while in view until someone touches it. Handy for demos.

It acts as a dialog: focus moves into the panel and goes back to the button when it closes, the page stops scrolling underneath, and the button’s aria-expanded is kept up to date. It fires draweropen and drawerclose events. From code: animacious.drawer(panel) opens it and animacious.drawer(panel, false) closes it.

1 ยท Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Side Drawer: a panel slides in from the side as the page behind it dims,
    // and its contents follow it in one after another. Esc, a tap on the dimmed
    // page or a swipe back towards the edge closes it.
    // Usage:
    //   <button data-drawer-open="#menu">Menu</button>
    //   <aside id="menu" data-side-drawer aria-label="Menu">
    //     <button data-drawer-close>Close</button>
    //     <a href="/">Home</a> โ€ฆ
    //   </aside>
    // Optional: data-side ("left", the default, or "right"), data-width (px,
    // default 320; never more than 85% of the screen), data-duration (ms,
    // default 480), data-contained (it slides in over its parent instead of the
    // whole page; give the parent position: relative and overflow: hidden),
    // data-autoplay (ms it stays open; it opens and closes by itself while in
    // view until someone touches it, handy for demos).
    // Events: "draweropen" and "drawerclose" on the drawer.
    // Script use: animacious.drawer(el) opens it, animacious.drawer(el, false) closes it.
    var DEFAULTS = { side: 'left', width: 320, duration: 480, autoplay: 2200 };

    var css =
      '.sdr{position:fixed;top:0;bottom:0;left:0;z-index:9001;max-width:85%;overflow-y:auto;overscroll-behavior:contain;visibility:hidden;touch-action:pan-y}' +
      '.sdr.sdr-r{left:auto;right:0}' +
      '.sdr.sdr-c,.sdr-dim.sdr-c{position:absolute}' +
      '.sdr.sdr-c{z-index:3}' +
      '.sdr-dim{position:fixed;inset:0;z-index:9000;background:rgba(14,10,28,.42);opacity:0;visibility:hidden;-webkit-tap-highlight-color:transparent}' +
      '.sdr-dim.sdr-c{z-index:2}' +
      ':where(.sdr){box-sizing:border-box;display:flex;flex-direction:column;gap:6px;margin:0;padding:22px 18px;border:0;background:#fff;color:#1a1625;box-shadow:0 0 50px rgba(14,10,28,.25);font:500 15px/1.4 system-ui,sans-serif}' +
      ':where(.sdr):focus{outline:none}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    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 b = {
        el: el, open: false,
        right: (d.side || DEFAULTS.side) === 'right',
        contained: on(d.contained),
        duration: +d.duration || DEFAULTS.duration,
        decorative: !!el.closest('[aria-hidden="true"]')
      };
      el._sdr = b;
      el.classList.add('sdr');
      if (b.right) el.classList.add('sdr-r');
      if (b.contained) el.classList.add('sdr-c');
      el.style.width = 'min(' + (+d.width || DEFAULTS.width) + 'px, 85%)';
      if (!b.decorative) {
        if (!el.getAttribute('role')) el.setAttribute('role', 'dialog');
        if (!b.contained) el.setAttribute('aria-modal', 'true');
        if (!el.hasAttribute('tabindex')) el.tabIndex = -1;
      }
      var dim = document.createElement('div');
      dim.className = 'sdr-dim' + (b.contained ? ' sdr-c' : '');
      dim.setAttribute('aria-hidden', 'true');
      el.parentNode.insertBefore(dim, el);
      b.dim = dim;
      anime.utils.set(el, { x: b.right ? '100%' : '-100%' });

      dim.addEventListener('click', function () { close(b); });
      el.addEventListener('click', function (e) {
        if (e.target.closest('[data-drawer-close]')) close(b);
      });
      el.addEventListener('keydown', function (e) {
        if (e.key === 'Escape') { e.stopPropagation(); close(b); }
      });
      swipe(b);
      return b;
    }

    // Drag the panel back towards its edge to close it.
    function swipe(b) {
      var el = b.el, start = null;
      el.addEventListener('pointerdown', function (e) {
        if (!b.open || e.button > 0 || e.target.closest('input,textarea,select')) return;
        start = { x: e.clientX, y: e.clientY, t: Date.now(), dx: 0, drag: false, id: e.pointerId };
      });
      el.addEventListener('pointermove', function (e) {
        if (!start || e.pointerId !== start.id) return;
        var dx = e.clientX - start.x, dy = e.clientY - start.y;
        if (!start.drag) {
          if (Math.abs(dx) < 8 || Math.abs(dx) < Math.abs(dy)) return;
          start.drag = true;
          try { el.setPointerCapture(e.pointerId); } catch (err) { /* fine */ }
          anime.utils.remove([el, b.dim]);
        }
        // Only towards the edge; a little resistance the other way.
        var away = b.right ? Math.max(0, dx) : Math.min(0, dx);
        start.dx = away;
        anime.utils.set(el, { x: away });
        anime.utils.set(b.dim, { opacity: 1 - Math.abs(away) / el.offsetWidth });
      });
      function end() {
        if (!start) return;
        var s = start; start = null;
        if (!s.drag) return;
        var speed = Math.abs(s.dx) / Math.max(1, Date.now() - s.t);
        if (Math.abs(s.dx) > el.offsetWidth * 0.3 || speed > 0.5) close(b, true);
        else {
          anime.animate(el, { x: 0, duration: 300, ease: 'out(3)' });
          anime.animate(b.dim, { opacity: 1, duration: 300, ease: 'out(2)' });
        }
      }
      el.addEventListener('pointerup', end);
      el.addEventListener('pointercancel', end);
    }

    function open(b) {
      var el = b.el;
      if (b.open) return;
      b.open = true;
      el.style.visibility = b.dim.style.visibility = 'visible';
      if (!b.decorative) {
        b.back = document.activeElement;
        if (!b.contained) {
          b.scroll = document.documentElement.style.overflow;
          document.documentElement.style.overflow = 'hidden';
        }
        document.querySelectorAll('[data-drawer-open]').forEach(function (t) {
          if (document.querySelector(t.getAttribute('data-drawer-open')) === el) t.setAttribute('aria-expanded', 'true');
        });
      }
      el.dispatchEvent(new CustomEvent('draweropen', { bubbles: true }));
      var items = Array.from(el.children);
      anime.utils.remove([el, b.dim].concat(items));
      if (reduced()) {
        anime.utils.set(el, { x: 0 });
        anime.utils.set(b.dim, { opacity: 1 });
        anime.utils.set(items, { opacity: 1, x: 0 });
      } else {
        var t = b.duration, dir = b.right ? 1 : -1;
        anime.animate(el, { x: [dir * el.offsetWidth, 0], duration: t, ease: 'out(4)' });
        anime.animate(b.dim, { opacity: [0, 1], duration: t * 0.8, ease: 'out(2)' });
        anime.animate(items, {
          opacity: [0, 1], x: [dir * 24, 0], duration: t * 0.9,
          delay: anime.stagger(Math.round(t / 12), { start: t * 0.25 }), ease: 'out(3)'
        });
      }
      if (!b.decorative) setTimeout(function () {
        if (!b.open) return;
        var first = el.querySelector('a[href],button:not([disabled]),input,select,textarea,[tabindex]:not([tabindex="-1"])');
        // A close button first in the panel is a fine first stop too, but don't
        // jump the page if it was opened by the autoplay.
        if (b.byUser) (first || el).focus({ preventScroll: true });
      }, 30);
    }

    function close(b, fromSwipe) {
      var el = b.el;
      if (!b.open) return;
      b.open = false;
      if (!b.decorative) {
        if (!b.contained) document.documentElement.style.overflow = b.scroll || '';
        document.querySelectorAll('[data-drawer-open]').forEach(function (t) {
          if (document.querySelector(t.getAttribute('data-drawer-open')) === el) t.setAttribute('aria-expanded', 'false');
        });
        if (el.contains(document.activeElement)) {
          if (b.back && b.back.focus) b.back.focus({ preventScroll: true });
          else document.activeElement.blur();
        }
      }
      el.dispatchEvent(new CustomEvent('drawerclose', { bubbles: true }));
      anime.utils.remove([el, b.dim]);
      var done = function () {
        if (b.open) return;
        el.style.visibility = b.dim.style.visibility = 'hidden';
      };
      if (reduced()) { anime.utils.set(el, { x: b.right ? '100%' : '-100%' }); anime.utils.set(b.dim, { opacity: 0 }); return done(); }
      var t = fromSwipe ? b.duration * 0.6 : b.duration * 0.75;
      anime.animate(el, { x: (b.right ? 1 : -1) * (el.offsetWidth + 40), duration: t, ease: fromSwipe ? 'out(2)' : 'inOut(3)', onComplete: done });
      anime.animate(b.dim, { opacity: 0, duration: t, ease: 'out(2)' });
    }

    // Demo mode: open, wait, close, wait, again, while the parent is in view.
    function autoplay(b, hold) {
      var screen = b.el.parentNode, timer = null, visible = false;
      function cycle() {
        clearTimeout(timer);
        if (!visible || !b.auto) return;
        timer = setTimeout(function () {
          if (!visible || !b.auto) return;
          open(b);
          timer = setTimeout(function () {
            if (!b.auto) return;
            close(b);
            timer = setTimeout(cycle, b.duration);
          }, hold);
        }, 900);
      }
      b.auto = true;
      screen.addEventListener('pointerdown', function (e) {
        if (!e.isTrusted || !b.auto) return;
        b.auto = false;
        clearTimeout(timer);
      });
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (visible && !b.open) cycle();
        else clearTimeout(timer);
      }, { threshold: 0.4 }).observe(screen);
    }

    function find(sel) {
      try { return document.querySelector(sel); } catch (e) { return null; }
    }

    document.addEventListener('click', function (e) {
      var t = e.target.closest && e.target.closest('[data-drawer-open]');
      if (!t) return;
      var el = find(t.getAttribute('data-drawer-open'));
      if (!el || !el.hasAttribute('data-side-drawer')) return;
      var b = el._sdr || setup(el);
      b.auto = false;
      b.byUser = true;
      if (b.open) close(b); else open(b);
      b.byUser = false;
    });
    document.addEventListener('keydown', function (e) {
      if (e.key !== 'Escape') return;
      document.querySelectorAll('.sdr').forEach(function (el) {
        if (el._sdr && el._sdr.open && !el._sdr.contained) close(el._sdr);
      });
    });

    function init() {
      document.querySelectorAll('[data-side-drawer]').forEach(function (el) {
        if (el._sdr) return;
        var b = setup(el);
        document.querySelectorAll('[data-drawer-open]').forEach(function (t) {
          if (find(t.getAttribute('data-drawer-open')) !== el) return;
          t.setAttribute('aria-expanded', 'false');
          if (el.id) t.setAttribute('aria-controls', el.id);
        });
        if (on(el.dataset.autoplay) && !reduced()) autoplay(b, +el.dataset.autoplay || DEFAULTS.autoplay);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.drawer = function (el, show) {
      var b = el._sdr || setup(el);
      if (show === false) close(b); else open(b);
    };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 ยท Your HTML
<button data-drawer-open="#menu">Menu</button>

<aside id="menu" data-side-drawer aria-label="Menu">
  <button data-drawer-close>Close</button>
  <a href="/">Home</a>
  <a href="/about">About</a>
</aside>