โ† Overlays

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.

Share

Drag the handle down, press Esc or tap the page to close it.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-bottom-sheet to a <dialog> with an id. A handle is added at the top for you.
  3. Add data-sheet-open="#that-id" to the button that opens it, and data-sheet-close to any button inside that closes it.

Options

Add any of these attributes to the same element.

data-bounceHow much the spring overshoots, 0–1. Default 0.3.
data-durationHow long the spring takes to settle. Default 560 ms.
data-max-widthWidest it gets, in px. Default 560; on narrower screens it spans the width.
data-sheet-handleOn 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.

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