Bottom Sheet
A panel springs up from the bottom of the screen over a dimmed page, the way phone apps share and pick things. Drag its handle down, or flick it, and it slides away at your speed. Built on the native <dialog>, so focus and Esc just work.
How to use it
- Paste the snippet just before
</head>. - Add
data-bottom-sheetto a<dialog>with anid. A handle is added at the top for you. - Add
data-sheet-open="#that-id"to the button that opens it, anddata-sheet-closeto any button inside that closes it.
Options
Add any of these attributes to the same element.
data-bounce | How much the spring overshoots, 0–1. Default 0.3. |
data-duration | How long the spring takes to settle. Default 560 ms. |
data-max-width | Widest it gets, in px. Default 560; on narrower screens it spans the width. |
data-sheet-handle | On any element inside: dragging it moves the sheet too. |
Esc, a tap on the dimmed page and a downward flick all close it, and focus goes back to the button that opened it. From code: animacious.sheet(dialog) opens it and animacious.sheet(dialog, false) closes it.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Bottom Sheet: a panel springs up from the bottom of the screen over a
// dimmed page. Drag its handle down (or flick it) to send it away; Esc, a
// tap on the dimmed page and any [data-sheet-close] button close it too.
// Built on the native <dialog>, so focus stays inside while it's open and
// goes back to the button that opened it.
// Usage:
// <button data-sheet-open="#share">Share</button>
// <dialog id="share" data-bottom-sheet aria-labelledby="share-title">
// <h2 id="share-title">Share</h2>
// โฆ
// <button data-sheet-close>Done</button>
// </dialog>
// Optional: data-bounce (spring bounce, 0โ1, default 0.3), data-duration
// (ms the spring takes to look settled, default 560), data-max-width (px,
// default 560; on narrower screens it spans the full width).
// Script use: animacious.sheet(el) opens it, animacious.sheet(el, false)
// closes it.
// Contained mode (for previews): put data-bottom-sheet on a <div> instead
// and it rises inside its parent (give the parent position: relative and
// overflow: hidden). There, data-autoplay (ms) opens and closes it on a
// timer while it's on screen.
var DEFAULTS = { bounce: 0.3, duration: 560, maxWidth: 560 };
var FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
var css =
'dialog.bsh{position:fixed;inset:auto 0 0 0;margin:0 auto;width:100%;max-width:var(--bsh-max,560px);max-height:min(85vh,calc(100% - 48px));' +
'border:0;border-radius:22px 22px 0 0;padding:0 24px calc(24px + env(safe-area-inset-bottom,0px));background:#fff;color:inherit;' +
'box-shadow:0 -18px 60px rgba(10,10,25,.22);overflow:auto;overscroll-behavior:contain}' +
'dialog.bsh::backdrop{background:rgba(10,10,25,.45);opacity:var(--bsh-dim,1)}' +
'.bsh-inline{position:absolute;left:0;right:0;bottom:0;z-index:2;margin:0 auto;max-width:var(--bsh-max,560px);visibility:hidden;' +
'border-radius:18px 18px 0 0;padding:0 18px 18px;background:#fff;box-shadow:0 -12px 40px rgba(10,10,25,.2)}' +
'.bsh-backdrop{position:absolute;inset:0;z-index:1;background:rgba(10,10,25,.4);opacity:0;visibility:hidden}' +
// The grab handle: a wide, easy target with a small pill drawn in it.
'.bsh-grip{position:sticky;top:0;z-index:1;display:grid;place-items:center;height:26px;margin:0 -24px 4px;background:inherit;cursor:grab;touch-action:none}' +
'.bsh-inline .bsh-grip{height:20px;margin:0 -18px 2px}' +
'.bsh-grip::before{content:"";width:40px;height:5px;border-radius:99px;background:#d4d4dc}' +
'.bsh-grip:active{cursor:grabbing}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function setup(el) {
var d = el.dataset;
var isDialog = el.tagName === 'DIALOG';
var inThumb = !!el.closest('[aria-hidden="true"]');
var s = { el: el, isDialog: isDialog, open: false, token: 0, prev: null, bd: null };
el._bsh = s;
el.classList.add(isDialog ? 'bsh' : 'bsh-inline');
el.style.setProperty('--bsh-max', (+d.maxWidth || DEFAULTS.maxWidth) + 'px');
var grip = document.createElement('div');
grip.className = 'bsh-grip';
grip.setAttribute('aria-hidden', 'true');
el.insertBefore(grip, el.firstChild);
if (isDialog) {
// Esc: slide away instead of vanishing. If the browser closes it anyway
// (a second Esc), tidy up.
el.addEventListener('cancel', function (e) { e.preventDefault(); toggle(el, false); });
el.addEventListener('close', function () { if (s.open) { s.open = false; s.token++; restoreFocus(s); } });
el.addEventListener('click', function (e) {
if (e.target !== el) return;
var r = el.getBoundingClientRect();
if (e.clientY < r.top || e.clientX < r.left || e.clientX > r.right) toggle(el, false);
});
} else {
var bd = document.createElement('div');
bd.className = 'bsh-backdrop';
bd.setAttribute('aria-hidden', 'true');
el.parentNode.insertBefore(bd, el);
bd.addEventListener('click', function () { toggle(el, false); });
s.bd = bd;
if (!inThumb) {
if (!el.getAttribute('role')) el.setAttribute('role', 'dialog');
el.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { e.preventDefault(); toggle(el, false); }
if (e.key !== 'Tab') return;
// Keep Tab inside the open sheet.
var f = Array.from(el.querySelectorAll(FOCUSABLE));
if (!f.length) return;
var first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
});
}
}
el.addEventListener('click', function (e) {
if (e.target.closest('[data-sheet-close]')) toggle(el, false);
});
// Dragging the handle: the sheet follows the finger, the page brightens
// as it goes, and on release it either springs back or slides away.
var drag = null;
el.addEventListener('pointerdown', function (e) {
if (!s.open || !e.target.closest('.bsh-grip,[data-sheet-handle]')) return;
e.preventDefault();
el.setPointerCapture(e.pointerId);
drag = { id: e.pointerId, y0: e.clientY, from: anime.utils.get(el, 'y', false) || 0, y: 0, v: 0, t: e.timeStamp, last: e.clientY };
anime.utils.remove(el);
});
el.addEventListener('pointermove', function (e) {
if (!drag || e.pointerId !== drag.id) return;
var y = Math.max(0, drag.from + e.clientY - drag.y0);
var dt = Math.max(1, e.timeStamp - drag.t);
drag.v = drag.v * 0.4 + ((e.clientY - drag.last) / dt) * 0.6;
drag.t = e.timeStamp;
drag.last = e.clientY;
drag.y = y;
anime.utils.set(el, { y: y });
dim(s, 1 - y / el.offsetHeight, 0);
});
function release(e) {
if (!drag || e.pointerId !== drag.id) return;
var g = drag;
drag = null;
if (g.y > el.offsetHeight * 0.35 || g.v > 0.55) close(s, g.v);
else settle(s);
}
el.addEventListener('pointerup', release);
el.addEventListener('pointercancel', release);
if (!isDialog && d.autoplay !== undefined && d.autoplay !== 'false' && !reduced()) {
var every = +d.autoplay || 2400, timer = null;
new IntersectionObserver(function (entries) {
clearInterval(timer);
if (!entries[0].isIntersecting) { if (s.open) toggle(el, false); return; }
timer = setInterval(function () { if (!el.closest('[hidden]')) toggle(el); }, every);
}).observe(el.parentNode);
}
return s;
}
function dim(s, to, duration) {
to = Math.max(0, Math.min(1, to));
if (s.isDialog) anime.animate(s.el, { '--bsh-dim': to, duration: duration, ease: 'out(2)' });
else anime.animate(s.bd, { opacity: to, duration: duration, ease: 'out(2)' });
}
function springEase(s) {
var d = s.el.dataset;
return anime.spring({ bounce: d.bounce !== undefined ? +d.bounce : DEFAULTS.bounce, duration: +d.duration || DEFAULTS.duration });
}
function settle(s) {
if (reduced()) { anime.utils.set(s.el, { y: 0 }); dim(s, 1, 0); return; }
anime.animate(s.el, { y: 0, ease: springEase(s) });
dim(s, 1, 250);
}
function restoreFocus(s) {
var p = s.prev;
s.prev = null;
if (p && p.focus && document.contains(p) && (document.activeElement === document.body || s.el.contains(document.activeElement))) p.focus();
}
function open(s) {
var el = s.el;
s.open = true;
++s.token;
if (!s.prev && !el.closest('[aria-hidden="true"]') && !el.contains(document.activeElement)) s.prev = document.activeElement;
// Still on screen (sliding away)? Then turn round from where it is.
var showing = s.isDialog ? el.open : el.style.visibility === 'visible';
if (s.isDialog) {
if (!el.open) el.showModal();
} else {
el.style.visibility = 'visible';
s.bd.style.visibility = 'visible';
if (s.prev) {
var f = el.querySelector(FOCUSABLE);
if (!el.hasAttribute('tabindex')) el.tabIndex = -1;
(f || el).focus({ preventScroll: true });
}
}
var h = el.offsetHeight;
if (reduced()) { anime.utils.set(el, { y: 0 }); dim(s, 1, 0); return; }
if (!showing) anime.utils.set(el, { y: h + 24 });
anime.animate(el, { y: 0, ease: springEase(s) });
dim(s, 1, 320);
}
function close(s, velocity) {
var el = s.el;
s.open = false;
var token = ++s.token;
var h = el.offsetHeight;
var done = function () {
if (token !== s.token) return;
if (s.isDialog) { if (el.open) el.close(); }
else { el.style.visibility = 'hidden'; s.bd.style.visibility = 'hidden'; }
restoreFocus(s);
};
if (reduced()) { dim(s, 0, 0); done(); return; }
var y = anime.utils.get(el, 'y', false) || 0;
var left = h + 24 - y;
// A flick keeps its speed; otherwise ease away gently.
var flick = velocity && velocity > 0.3;
var duration = flick ? Math.max(140, Math.min(360, left / velocity)) : 300;
anime.animate(el, { y: h + 24, duration: duration, ease: flick ? 'out(1.6)' : 'in(2)', onComplete: done });
dim(s, 0, duration);
}
function toggle(el, want) {
var s = el._bsh || setup(el);
if (want === undefined) want = !s.open;
if (want === s.open) return;
if (want) open(s); else close(s);
}
function init() {
document.querySelectorAll('[data-bottom-sheet]').forEach(function (el) {
if (el._bsh) return;
setup(el);
});
document.addEventListener('click', function (e) {
var t = e.target.closest && e.target.closest('[data-sheet-open]');
if (!t) return;
var el = document.querySelector(t.getAttribute('data-sheet-open'));
if (!el) return;
e.preventDefault();
if (!el._bsh) setup(el);
el._bsh.prev = t;
toggle(el, true);
});
}
window.animacious = window.animacious || {};
window.animacious.sheet = function (el, open) { toggle(el, open); };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><button data-sheet-open="#share">Share</button>
<dialog id="share" data-bottom-sheet aria-labelledby="share-title">
<h2 id="share-title">Share</h2>
<p>โฆ</p>
<button data-sheet-close>Done</button>
</dialog>