Notification Pop
Notification Pop
Toast notifications spring in from the bottom-right using elastic overshoot. A shrinking progress bar drives auto-dismiss after 4 seconds. Pause on hover. Stack up to 4.
Dependencies
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Notification Pop — Motion Recipe</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/motion@11.11.13/dist/motion.js"></script>
<style>
.toast {
pointer-events: none;
opacity: 0;
transform: translateY(12px) scale(0.95);
}
.toast-progress {
transform-origin: left;
}
</style>
</head>
<body class="bg-slate-950 text-slate-200 font-sans">
<div class="mx-auto max-w-3xl px-8 pt-20 pb-48">
<span class="mb-6 inline-block rounded-full border border-violet-500/20 bg-violet-500/10 px-3.5 py-1 text-[0.7rem] font-semibold uppercase tracking-widest text-violet-400">Notification Pop</span>
<h1 class="mb-4 text-4xl font-black leading-tight text-slate-100">Spring-in toasts<br>with progress.</h1>
<p class="mb-12 max-w-sm text-base leading-relaxed text-slate-400">Toasts spring in from the bottom-right with elastic overshoot, then dismiss themselves via a shrinking progress bar. Stack up to 4.</p>
<div class="flex flex-wrap gap-3">
<button onclick="showToast('success')"
class="rounded-xl bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-emerald-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950">
Success
</button>
<button onclick="showToast('error')"
class="rounded-xl bg-red-600 px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-red-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950">
Error
</button>
<button onclick="showToast('info')"
class="rounded-xl bg-violet-600 px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-violet-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950">
Info
</button>
<button onclick="showToast('warning')"
class="rounded-xl bg-amber-500 px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-amber-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950">
Warning
</button>
</div>
</div>
<!-- Toast container -->
<div id="toast-container"
class="fixed bottom-6 right-6 flex flex-col-reverse gap-3 z-50"
role="region"
aria-label="Notifications"
aria-live="polite">
</div>
<script>
var { animate } = Motion;
var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var SPRING = [0.16, 1, 0.3, 1];
var DURATION = 4000;
var MAX_TOASTS = 4;
var TYPES = {
success: {
icon: '<svg class="h-5 w-5 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>',
bar: 'bg-emerald-500',
title: 'Saved successfully',
body: 'Your changes have been saved.',
},
error: {
icon: '<svg class="h-5 w-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>',
bar: 'bg-red-500',
title: 'Something went wrong',
body: 'Please try again in a moment.',
},
info: {
icon: '<svg class="h-5 w-5 text-violet-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01"/></svg>',
bar: 'bg-violet-500',
title: 'New feature available',
body: 'Check out what\'s new in the changelog.',
},
warning: {
icon: '<svg class="h-5 w-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/></svg>',
bar: 'bg-amber-400',
title: 'Storage almost full',
body: 'You\'re at 90% capacity. Upgrade your plan.',
},
};
function showToast(type) {
var container = document.getElementById('toast-container');
// Limit stack
while (container.children.length >= MAX_TOASTS) {
dismissToast(container.lastElementChild, false);
}
var cfg = TYPES[type];
var toast = document.createElement('div');
toast.className = 'toast relative w-80 overflow-hidden rounded-2xl border border-white/8 bg-slate-900 p-4 shadow-2xl';
toast.setAttribute('role', 'status');
toast.innerHTML =
'<div class="flex items-start gap-3">' +
cfg.icon +
'<div class="flex-1 min-w-0">' +
'<p class="text-sm font-semibold text-slate-100">' + cfg.title + '</p>' +
'<p class="mt-0.5 text-xs text-slate-400">' + cfg.body + '</p>' +
'</div>' +
'<button onclick="dismissToast(this.closest(\'.toast\'), true)" ' +
'class="shrink-0 rounded-lg p-1 text-slate-500 transition-colors hover:text-slate-300 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-slate-400" ' +
'aria-label="Dismiss">' +
'<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>' +
'</button>' +
'</div>' +
'<div class="toast-progress absolute bottom-0 left-0 h-0.5 w-full ' + cfg.bar + '"></div>';
container.prepend(toast);
toast.style.pointerEvents = 'auto';
// Spring entrance
if (!reduced) {
animate(toast, { opacity: [0, 1], y: [16, 0], scale: [0.94, 1] }, {
duration: 0.4, easing: SPRING,
});
} else {
toast.style.opacity = '1';
toast.style.transform = 'none';
}
// Progress bar countdown
var bar = toast.querySelector('.toast-progress');
var timer;
if (!reduced) {
var progressAnim = animate(bar, { scaleX: [1, 0] }, {
duration: DURATION / 1000,
easing: 'linear',
});
timer = setTimeout(function() { dismissToast(toast, false); }, DURATION);
// Pause on hover
toast.addEventListener('mouseenter', function() {
progressAnim.pause();
clearTimeout(timer);
});
toast.addEventListener('mouseleave', function() {
progressAnim.play();
timer = setTimeout(function() { dismissToast(toast, false); }, progressAnim.duration * (1 - progressAnim.currentTime / progressAnim.duration) * 1000);
});
} else {
bar.style.display = 'none';
timer = setTimeout(function() { dismissToast(toast, false); }, DURATION);
}
toast._timer = timer;
}
function dismissToast(toast, userDismissed) {
clearTimeout(toast._timer);
if (!reduced) {
animate(toast, { opacity: [1, 0], y: [0, 8], scale: [1, 0.96] }, {
duration: 0.25, easing: 'ease-in',
}).then(function() { toast.remove(); });
} else {
toast.remove();
}
}
</script>
</body>
</html>
Each toast enters with { opacity: [0,1], y: [16,0], scale: [0.94,1] } using the spring easing [0.16, 1, 0.3, 1]. The y: 16→0 lifts the element off the baseline; the scale: 0.94→1 adds the elastic pop. Together they read as a physical object arriving at its resting place.
The progress bar animates scaleX: [1, 0] with easing: 'linear' over the dismiss timeout. transform-origin: left ensures the bar depletes left-to-right.
Pause on hover: progressAnim.pause() and progressAnim.play() from the Motion One animation instance suspend and resume the countdown. The remaining time is recalculated from progressAnim.currentTime on resume to avoid resetting the countdown.
Stack management: container.prepend(toast) pushes new toasts to the top of a flex-col-reverse container — each new toast appears above the previous. When MAX_TOASTS (4) is reached, the oldest toast is dismissed before inserting.
Accessibility: The container uses role="region" and aria-live="polite". Each toast is role="status". Screen readers announce new toasts without interrupting current speech. The dismiss button has a visible aria-label.
prefers-reduced-motion hides the progress bar, skips all animations, and still auto-dismisses on the same timer.