Toast Notifcations by Artclick

by artclick

HTML

<div class="demo-panel">
  <button id="btnDefault">Show toast</button>
  <button id="btnSuccess" class="success">Success</button>
  <button id="btnError" class="error">Error</button>
  <button id="btnWarning" class="warning">Warning</button>
  <button id="btnTheme" class="theme">Toggle theme</button>
</div>

<div class="toast-region" id="toastRegion" role="region" aria-label="Notifications"></div>

CSS

:root {
  --toast-bg: #1f2430;
  --toast-text: #ffffff;
  --page-bg: #f4f5f7;
  --page-text: #1a1a1a;
}

[data-theme="dark"] {
  --toast-bg: #2a2f3d;
  --toast-text: #e8eaed;
  --page-bg: #14171c;
  --page-text: #e8eaed;
}

* {
  box-sizing: border-box;
}

body {
  min-height: 100vh;
  margin: 0;
  background: var(--page-bg);
  color: var(--page-text);
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 2rem;
}

.demo-panel {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
  max-width: 420px;
}

.demo-panel button {
  border: none;
  border-radius: 0.5rem;
  padding: 0.7rem 1.1rem;
  font-size: 0.9rem;
  cursor: pointer;
  background: #2f3542;
  color: #fff;
  transition: transform 0.15s ease;
}

.demo-panel button:hover {
  transform: translateY(-1px);
}

.demo-panel button.success { background: #1b7a4d; }
.demo-panel button.error   { background: #a3312f; }
.demo-panel button.warning { background: #a6741b; }
.demo-panel button.theme   { background: #444b5c; }

/* Toast region + toasts */

.toast-region {
  position: fixed;
  bottom: 1.5rem;
  right: 1.5rem;
  display: flex;
  flex-direction: column-reverse;
  gap: 0.6rem;
  z-index: 1000;
  pointer-events: none;
}

.toast {
  pointer-events: auto;
  display: flex;
  align-items: center;
  gap: 0.75rem;
  min-width: 260px;
  max-width: 360px;
  padding: 0.85rem 1rem;
  border-radius: 0.6rem;
  background: var(--toast-bg);
  color: var(--toast-text);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
  animation: toast-in 0.25s ease-out;
  touch-action: pan-y;
}

.toast-message {
  flex: 1;
  margin: 0;
  font-size: 0.9rem;
  line-height: 1.4;
}

.toast-close {
  flex-shrink: 0;
  border: none;
  background: transparent;
  color: inherit;
  opacity: 0.7;
  font-size: 1.1rem;
  line-height: 1;
  cursor: pointer;
 ...

JavaScript

const region = document.getElementById('toastRegion');
const MAX_VISIBLE = 4;
const DEFAULT_DURATION = 4000;

function showToast(message, { duration = DEFAULT_DURATION, urgent = false, variant = '' } = {}) {
  const toast = document.createElement('div');
  toast.className = `toast${variant ? ` toast--${variant}` : ''}`;
  toast.setAttribute('role', urgent ? 'alert' : 'status');
  toast.setAttribute('aria-live', urgent ? 'assertive' : 'polite');

  toast.innerHTML = `
    <p class="toast-message"></p>
    <button class="toast-close" aria-label="Dismiss notification">&times;</button>
  `;
  // textContent, not innerHTML — keeps user-supplied text from being parsed as markup
  toast.querySelector('.toast-message').textContent = message;

  region.appendChild(toast);
  enforceMaxVisible();
  makeSwipeable(toast);

  let timer = startTimer(toast, duration);
  toast.addEventListener('mouseenter', () => clearTimeout(timer));
  toast.addEventListener('mouseleave', () => {
    timer = startTimer(toast, duration);
  });
  toast.querySelector('.toast-close').addEventListener('click', () => dismiss(toast));

  return toast;
}

function startTimer(toast, duration) {
  return setTimeout(() => dismiss(toast), duration);
}

function dismiss(toast) {
  if (toast.classList.contains('toast--leaving')) return;
  toast.classList.add('toast--leaving');
  toast.addEventListener('animationend', () => toast.remove(), { once: true });
}

function enforceMaxVisible() {
  const toasts = region.querySelectorAll('.toast:not(.toast--leaving)');
  if (toasts.length > MAX_VISIBLE) {
    dismiss(toasts[0]);
  }
}

function makeSwipeable(toast) {
  let startX = 0;
  let currentX = 0;
  let dragging = false;

  toast.addEventListener('pointerdown', (e) => {
    dragging = true;
    startX = e.clientX;
    toast.style.transition = 'none';
  });

  toast.addEventListener('pointermove', (e) => {
    if (!dragging) return;
    currentX =...