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
- Paste the snippet just before
</head>. - Add
data-side-drawerto the panel (an<aside>or<nav>) and give it anid. - Add
data-drawer-open="#that-id"to the button that opens it, anddata-drawer-closeto anything inside that should close it.
Options
Add any of these attributes to the same element.
data-side | left or right. Default left. |
data-width | Width in px; it never takes more than 85% of the screen. Default 320. |
data-duration | Time to slide in. Default 480 ms. |
data-contained | Slide in over its parent instead of the whole page. Give the parent position: relative and overflow: hidden. |
data-autoplay | Time 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.
<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><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>