โ† Overlays

Phone Notification Drop

A frosted, phone-style notification drops in from the top of the screen with a soft bounce, waits a moment and then tucks itself away upwards. Swipe it up or tap it to send it off sooner; hover to keep it down.

9:41Tuesday 9 June
๐Ÿ’ฌMessagesnowMayaRunning 5 minutes late, save me a seat!
๐Ÿ“ฆParcelsnowOut for deliveryYour order arrives between 2 and 4 pm.
๐Ÿ“…CalendarnowDesign reviewStarts in 10 minutes in Room 3.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-phone-notification-drop and an id to your notification.
  3. Add data-notification-show="#that-id" to the button that shows it, or call it from code.

Options

Add any of these attributes to the same element.

data-holdHow long it stays. Default 3600 ms; 0 stays until it’s swiped away.
data-durationTime to drop in. Default 700 ms.
data-bounceHow much it bounces as it lands, 0–1. Default 0.35.
data-containedDrop from the top of its parent instead of the window, for app frames and previews. Give the parent position: relative and overflow: hidden.
data-autoplayDrops in by itself while in view. Several in the same parent take turns.
data-gapTime between autoplay drops. Default 900 ms.

It’s a role="status" region, so screen readers read it out when it arrives. It fires notificationshow and notificationhide events. From code: animacious.notify(el).

1 ยท Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Phone Notification Drop: a phone-style notification banner drops in from
    // the top of the screen with a soft bounce, waits a moment, then tucks
    // itself away upwards. Swipe it up, or tap it, to send it away sooner.
    // Usage:
    //   <div id="msg" data-phone-notification-drop>
    //     <b>Messages</b> <p>Maya: Running 5 minutes late!</p>
    //   </div>
    //   <button data-notification-show="#msg">Notify me</button>
    // Optional: data-hold (ms it stays, default 3600; 0 stays until swiped),
    // data-duration (ms to drop in, default 700), data-bounce (0โ€“1, default
    // 0.35), data-contained (drop from the top of its parent instead of the
    // window; give the parent position: relative and overflow: hidden),
    // data-autoplay (drops in by itself while in view; several notifications
    // with data-autoplay in the same parent take turns), data-gap (ms between
    // autoplay drops, default 900).
    // Events: "notificationshow" and "notificationhide" on the notification.
    // Script use: animacious.notify(el) drops it in.
    var DEFAULTS = { hold: 3600, duration: 700, bounce: 0.35, gap: 900 };

    var css =
      '.pnd{position:fixed;left:0;right:0;top:10px;z-index:9300;box-sizing:border-box;width:min(400px,calc(100% - 20px));margin:0 auto;visibility:hidden;touch-action:none;cursor:grab;-webkit-user-select:none;user-select:none}' +
      '.pnd.pnd-c{position:absolute;z-index:5;top:8px;width:calc(100% - 16px)}' +
      ':where(.pnd){padding:12px 14px;border-radius:20px;background:rgba(250,248,255,.82);color:#1a1625;box-shadow:0 12px 34px rgba(10,8,25,.22);backdrop-filter:blur(18px) saturate(1.6);-webkit-backdrop-filter:blur(18px) saturate(1.6);font:14px/1.35 system-ui,sans-serif;text-align:left}';
    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 n = {
        el: el, open: false, timer: null,
        hold: d.hold !== undefined ? +d.hold : DEFAULTS.hold,
        duration: +d.duration || DEFAULTS.duration,
        bounce: d.bounce !== undefined ? +d.bounce : DEFAULTS.bounce,
        deco: !!el.closest('[aria-hidden="true"]')
      };
      el._pnd = n;
      el.classList.add('pnd');
      if (on(d.contained)) el.classList.add('pnd-c');
      if (!n.deco) {
        if (!el.getAttribute('role')) el.setAttribute('role', 'status');
        el.inert = true;
      }
      // Hovering or holding keeps it down; letting go starts the clock again.
      el.addEventListener('pointerenter', function (e) { if (e.pointerType === 'mouse') pause(n); });
      el.addEventListener('pointerleave', function (e) { if (e.pointerType === 'mouse' && n.open && !n.drag) wait(n); });
      swipe(n);
      return n;
    }

    function away(n) { return -(n.el.offsetHeight + n.el.offsetTop + 24); }

    function show(n) {
      var el = n.el;
      if (n.open) return wait(n);
      n.open = true;
      el.style.visibility = 'visible';
      if (!n.deco) el.inert = false;
      el.dispatchEvent(new CustomEvent('notificationshow', { bubbles: true }));
      var kids = Array.from(el.children);
      anime.utils.remove([el].concat(kids));
      if (reduced()) {
        anime.utils.set(el, { y: 0, scale: 1, opacity: 1 });
      } else {
        anime.animate(el, {
          y: [away(n), 0], scale: [0.94, 1],
          opacity: { to: [0, 1], duration: 180, ease: 'linear' },
          ease: anime.spring({ bounce: n.bounce, duration: n.duration })
        });
        anime.animate(kids, { opacity: [0, 1], y: [-6, 0], duration: 420, delay: anime.stagger(50, { start: 140 }), ease: 'out(3)' });
      }
      wait(n);
    }

    function wait(n) {
      pause(n);
      if (n.hold > 0) n.timer = setTimeout(function () { hide(n); }, n.hold);
    }
    function pause(n) { clearTimeout(n.timer); n.timer = null; }

    function hide(n, fast) {
      var el = n.el;
      if (!n.open) return;
      n.open = false;
      pause(n);
      if (!n.deco) {
        if (el.contains(document.activeElement)) document.activeElement.blur();
        el.inert = true;
      }
      el.dispatchEvent(new CustomEvent('notificationhide', { bubbles: true }));
      var done = function () { if (!n.open) el.style.visibility = 'hidden'; if (n.after) n.after(); };
      anime.utils.remove(el);
      if (reduced()) { anime.utils.set(el, { opacity: 0 }); return done(); }
      // Tuck away: shrink a touch and slip back up under the top edge.
      anime.animate(el, { y: away(n), scale: 0.9, opacity: { to: 0, delay: fast ? 120 : 220, duration: 200 }, duration: fast ? 300 : 460, ease: 'in(3)', onComplete: done });
    }

    // Drag it up to dismiss; a tap sends it away too.
    function swipe(n) {
      var el = n.el, p = null;
      el.addEventListener('pointerdown', function (e) {
        if (!n.open || e.button > 0) return;
        if (e.isTrusted && n.group) n.group.stop();
        pause(n);
        anime.utils.remove(el);
        p = { y: e.clientY, t: Date.now(), dy: 0 };
        n.drag = true;
        el.setPointerCapture(e.pointerId);
      });
      el.addEventListener('pointermove', function (e) {
        if (!p) return;
        p.dy = e.clientY - p.y;
        // Free upwards, stiff downwards.
        anime.utils.set(el, { y: p.dy < 0 ? p.dy : Math.sqrt(p.dy) * 3, scale: 1 });
      });
      function end() {
        if (!p) return;
        var q = p, v = q.dy / Math.max(1, Date.now() - q.t);
        p = null;
        n.drag = false;
        if (Math.abs(q.dy) < 6 || q.dy < -30 || v < -0.4) return hide(n, true);
        anime.animate(el, { y: 0, ease: anime.spring({ bounce: 0.4, duration: 450 }) });
        wait(n);
      }
      el.addEventListener('pointerup', end);
      el.addEventListener('pointercancel', end);
    }

    // Autoplay: the notifications in one parent drop in one after another.
    function autoplay(parent, list) {
      var g = { i: 0, visible: false, live: true, timer: null };
      function next() {
        clearTimeout(g.timer);
        if (!g.visible || !g.live) return;
        var n = list[g.i++ % list.length];
        n.after = function () {
          n.after = null;
          g.timer = setTimeout(next, +n.el.dataset.gap || DEFAULTS.gap);
        };
        show(n);
      }
      g.stop = function () {
        g.live = false;
        clearTimeout(g.timer);
        list.forEach(function (n) { n.after = null; });
      };
      list.forEach(function (n) { n.group = g; });
      new IntersectionObserver(function (entries) {
        g.visible = entries[entries.length - 1].isIntersecting;
        if (!g.visible) clearTimeout(g.timer);
        else if (!list.some(function (n) { return n.open; })) g.timer = setTimeout(next, 600);
      }, { threshold: 0.4 }).observe(parent);
    }

    function init() {
      var byParent = new Map();
      document.querySelectorAll('[data-phone-notification-drop]').forEach(function (el) {
        if (el._pnd) return;
        var n = setup(el);
        if (on(el.dataset.autoplay) && !reduced()) {
          if (!byParent.has(el.parentNode)) byParent.set(el.parentNode, []);
          byParent.get(el.parentNode).push(n);
        }
      });
      byParent.forEach(function (list, parent) { autoplay(parent, list); });
    }

    document.addEventListener('click', function (e) {
      var b = e.target.closest && e.target.closest('[data-notification-show]');
      if (!b) return;
      var el = document.querySelector(b.getAttribute('data-notification-show'));
      if (!el) return;
      var n = el._pnd || setup(el);
      if (n.group) n.group.stop();
      // Only one at a time in the same place.
      Array.from(el.parentNode.children).forEach(function (s) { if (s !== el && s._pnd && s._pnd.open) hide(s._pnd, true); });
      show(n);
    });

    window.animacious = window.animacious || {};
    window.animacious.notify = function (el) { show(el._pnd || setup(el)); };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 ยท Your HTML
<div id="late" data-phone-notification-drop>
  <b>Messages</b>
  <p>Maya: Running 5 minutes late, save me a seat!</p>
</div>

<button data-notification-show="#late">Notify me</button>