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
- Paste the snippet just before
</head>. - Add
data-phone-notification-dropand anidto your notification. - 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-hold | How long it stays. Default 3600 ms; 0 stays until it’s swiped away. |
data-duration | Time to drop in. Default 700 ms. |
data-bounce | How much it bounces as it lands, 0–1. Default 0.35. |
data-contained | Drop from the top of its parent instead of the window, for app frames and previews. Give the parent position: relative and overflow: hidden. |
data-autoplay | Drops in by itself while in view. Several in the same parent take turns. |
data-gap | Time 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).
<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><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>