Tailwind UI Pattern Registry for humans and agents

toast notification spring pop progress stack dismiss motion-one accessible motion-one

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.

Motion One programmatic
Live Preview

Dependencies

Motion v11.11.13
HTML
<!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.