Skip to content
UI ComponentNotification & ToastFeedbackNew

Toast Notifications with Promise, Undo & Swipe

A tiny toast notification library with success, error, warning and info toasts, promise toasts that go from loading to done, undo actions, six screen positions, a time-left progress bar that pauses on hover, swipe-to-dismiss on mouse and touch, smooth stacking with a max count, and animated icons. Comes with a playground to try every option.

  • Published 15 Oct 2026
  • 7 views
Toast Notifications with Promise, Undo & Swipe — preview

Overview

Toast Notifications with Promise, Undo & Swipe

Toasts people actually notice — four types, promise toasts, undo, six positions and swipe to dismiss. One small script, no dependencies.


What's Included

  • toast.success / error / warning / info one-liners
  • toast.promise() — loading → success or error in one toast
  • Action buttons like Undo
  • Six positions (top/bottom × left/center/right)
  • Progress bar shows time left and pauses on hover
  • Swipe sideways to dismiss (mouse and touch)
  • Stack limit, smooth collapse and aria-live announcements

Dependencies

Already linked inside the component's files — no extra setup needed.


Desktop Preview


HTML

html
1<!doctype html>
2<html lang="en">
3  <head>
4    <meta charset="UTF-8" />
5    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6    <title>Toast Notifications | MetaSkillGroup</title>
7    <link rel="stylesheet" href="style.css" />
8  </head>
9  <body>
10    <!-- created by metaskillgroup-team -->
11    <main class="page">
12      <header class="intro">
13        <span class="eyebrow">Notification &amp; Toast</span>
14        <h1>Toasts people <span>actually notice</span></h1>
15        <p>Four types, promise toasts, undo actions and six positions. Hover to pause, swipe to dismiss.</p>
16      </header>
17
18      <section class="playground">
19        <div class="group">
20          <h2>Fire a toast</h2>
21          <div class="fire">
22            <button type="button" class="fire-btn success" data-demo="success"><i></i>Success</button>
23            <button type="button" class="fire-btn error" data-demo="error"><i></i>Error</button>
24            <button type="button" class="fire-btn warning" data-demo="warning"><i></i>Warning</button>
25            <button type="button" class="fire-btn info" data-demo="info"><i></i>Info</button>
26            <button type="button" class="fire-btn promise" data-demo="promise"><i></i>Promise</button>
27            <button type="button" class="fire-btn undo" data-demo="undo"><i></i>With undo</button>
28          </div>
29        </div>
30
31        <div class="group">
32          <h2>Position</h2>
33          <div class="positions" role="radiogroup" aria-label="Toast position">
34            <button type="button" role="radio" data-pos="top-left" aria-label="Top left"></button>
35            <button type="button" role="radio" data-pos="top-center" aria-label="Top center"></button>
36            <button type="button" role="radio" data-pos="top-right" aria-label="Top right"></button>
37            <button type="button" role="radio" data-pos="bottom-left" aria-label="Bottom left"></button>
38            <button type="button" role="radio" data-pos="bottom-center" aria-label="Bottom center"></button>
39            <button type="button" role="radio" data-pos="bottom-right" aria-label="Bottom right" aria-checked="true"></button>
40          </div>
41        </div>
42
43        <pre class="code"><code>toast.success("Changes saved", "Your profile is up to date.");
44toast.promise(upload(), { loading: "Uploading…", success: "Uploaded!" });</code></pre>
45      </section>
46    </main>
47
48    <!-- Toasts live here -->
49    <section class="toaster bottom-right" id="toaster" aria-live="polite" aria-label="Notifications"></section>
50
51    <script src="script.js"></script>
52  </body>
53</html>

CSS

css
1/* Importing Google Fonts - Rubik + JetBrains Mono */
2@import url("https://fonts.googleapis.com/css2?family=Rubik:wght@400;500;600;700&family=JetBrains+Mono:wght@500&display=swap");
3
4* {
5  margin: 0;
6  padding: 0;
7  box-sizing: border-box;
8  font-family: "Rubik", sans-serif;
9}
10
11:root {
12  --bg: #f4f5f9;
13  --card: #ffffff;
14  --text: #171a26;
15  --muted: #6b7086;
16  --border: #e3e5ee;
17  --success: #16a34a;
18  --error: #e5383b;
19  --warning: #e8890c;
20  --info: #2f6bff;
21  --promise: #7c3aed;
22}
23
24body {
25  min-height: 100vh;
26  background:
27    radial-gradient(circle at 0% 0%, rgba(47, 107, 255, 0.08), transparent 35%),
28    radial-gradient(circle at 100% 100%, rgba(124, 58, 237, 0.08), transparent 35%),
29    var(--bg);
30  color: var(--text);
31}
32
33.page {
34  display: grid;
35  align-content: center;
36  min-height: 100vh;
37  max-width: 760px;
38  margin: 0 auto;
39  padding: 70px 20px 120px;
40}
41
42/* ---------- Intro ---------- */
43.intro {
44  margin-bottom: 40px;
45  text-align: center;
46}
47
48.eyebrow {
49  color: var(--info);
50  font-size: 0.8rem;
51  font-weight: 700;
52  letter-spacing: 0.14em;
53  text-transform: uppercase;
54}
55
56.intro h1 {
57  margin: 14px 0 12px;
58  font-size: clamp(2rem, 5vw, 3rem);
59  font-weight: 700;
60  letter-spacing: -0.03em;
61  line-height: 1.1;
62  text-wrap: balance;
63}
64
65.intro h1 span {
66  background: linear-gradient(90deg, var(--info), var(--promise));
67  -webkit-background-clip: text;
68  background-clip: text;
69  color: transparent;
70}
71
72.intro p {
73  color: var(--muted);
74  font-size: 1.05rem;
75  line-height: 1.6;
76}
77
78/* ---------- Playground ---------- */
79.playground {
80  display: grid;
81  grid-template-columns: 1fr auto;
82  gap: 28px;
83  padding: 28px;
84  border: 1px solid var(--border);
85  border-radius: 24px;
86  background: var(--card);
87  box-shadow: 0 30px 70px -40px rgba(23, 26, 38, 0.3);
88}
89
90.group h2 {
91  margin-bottom: 14px;
92  color: var(--muted);
93  font-size: 0.8rem;
94  font-weight: 600;
95  letter-spacing: 0.06em;
96  text-transform: uppercase;
97}
98
99.fire {
100  display: grid;
101  grid-template-columns: repeat(3, 1fr);
102  gap: 10px;
103}
104
105.fire-btn {
106  display: flex;
107  align-items: center;
108  gap: 9px;
109  padding: 12px 14px;
110  border: 1px solid var(--border);
111  border-radius: 12px;
112  background: #fafbfd;
113  color: var(--text);
114  font-size: 0.93rem;
115  font-weight: 500;
116  cursor: pointer;
117  transition: transform 0.15s, border-color 0.2s, box-shadow 0.2s;
118}
119
120.fire-btn:hover {
121  border-color: currentColor;
122  box-shadow: 0 6px 18px -10px currentColor;
123}
124
125.fire-btn:active {
126  transform: scale(0.97);
127}
128
129.fire-btn:focus-visible,
130.positions button:focus-visible {
131  outline: 3px solid rgba(47, 107, 255, 0.4);
132  outline-offset: 2px;
133}
134
135.fire-btn i {
136  width: 10px;
137  height: 10px;
138  border-radius: 50%;
139  background: var(--c);
140}
141
142.fire-btn.success { --c: var(--success); }
143.fire-btn.error { --c: var(--error); }
144.fire-btn.warning { --c: var(--warning); }
145.fire-btn.info { --c: var(--info); }
146.fire-btn.promise { --c: var(--promise); }
147.fire-btn.undo { --c: var(--text); }
148
149/* Mini screen to pick a corner */
150.positions {
151  display: grid;
152  grid-template-columns: repeat(3, 34px);
153  grid-template-rows: 34px 34px;
154  justify-content: space-between;
155  align-content: space-between;
156  width: 150px;
157  height: 104px;
158  padding: 8px;
159  border: 2px solid var(--border);
160  border-radius: 14px;
161  background: #fafbfd;
162}
163
164.positions button {
165  border: 0;
166  border-radius: 8px;
167  background: #e6e8f1;
168  cursor: pointer;
169  transition: background 0.2s, transform 0.2s;
170}
171
172.positions button:hover {
173  transform: scale(1.08);
174}
175
176.positions button[aria-checked="true"] {
177  background: var(--info);
178}
179
180.code {
181  grid-column: 1 / -1;
182  padding: 16px 18px;
183  border-radius: 14px;
184  background: #11131c;
185  color: #cdd3f0;
186  font-family: "JetBrains Mono", monospace;
187  font-size: 0.8rem;
188  line-height: 1.7;
189  overflow-x: auto;
190}
191
192.code code {
193  font-family: inherit;
194}
195
196/* =========================================================
197   Toaster
198   ========================================================= */
199.toaster {
200  position: fixed;
201  z-index: 100;
202  display: flex;
203  flex-direction: column;
204  gap: 10px;
205  width: min(370px, calc(100vw - 32px));
206  pointer-events: none;
207}
208
209.toaster.top-left,
210.toaster.top-center,
211.toaster.top-right {
212  top: 20px;
213}
214
215.toaster.bottom-left,
216.toaster.bottom-center,
217.toaster.bottom-right {
218  bottom: 20px;
219  flex-direction: column-reverse;
220}
221
222.toaster.top-left,
223.toaster.bottom-left {
224  left: 20px;
225}
226
227.toaster.top-right,
228.toaster.bottom-right {
229  right: 20px;
230}
231
232.toaster.top-center,
233.toaster.bottom-center {
234  left: 50%;
235  transform: translateX(-50%);
236}
237
238.toast {
239  --c: var(--info);
240  position: relative;
241  display: grid;
242  grid-template-columns: auto 1fr auto;
243  align-items: start;
244  gap: 12px;
245  padding: 14px 14px 16px;
246  border: 1px solid var(--border);
247  border-radius: 16px;
248  background: var(--card);
249  box-shadow: 0 18px 40px -18px rgba(23, 26, 38, 0.35), 0 2px 6px rgba(23, 26, 38, 0.05);
250  overflow: hidden;
251  pointer-events: auto;
252  touch-action: pan-y;
253  user-select: none;
254  animation: toastIn 0.45s cubic-bezier(0.2, 0.9, 0.3, 1.2);
255}
256
257.top-left .toast,
258.top-center .toast,
259.top-right .toast {
260  --from: -20px;
261}
262
263.toast {
264  --from: 20px;
265}
266
267@keyframes toastIn {
268  from {
269    opacity: 0;
270    transform: translateY(var(--from)) scale(0.94);
271  }
272}
273
274.toast.success { --c: var(--success); }
275.toast.error { --c: var(--error); }
276.toast.warning { --c: var(--warning); }
277.toast.info { --c: var(--info); }
278.toast.loading { --c: var(--promise); }
279
280/* Coloured strip on the left */
281.toast::before {
282  content: "";
283  position: absolute;
284  top: 0;
285  bottom: 0;
286  left: 0;
287  width: 4px;
288  background: var(--c);
289}
290
291.t-icon {
292  display: grid;
293  place-items: center;
294  width: 30px;
295  height: 30px;
296  border-radius: 50%;
297  background: color-mix(in srgb, var(--c) 14%, transparent);
298  color: var(--c);
299}
300
301.t-icon svg {
302  width: 18px;
303  height: 18px;
304  fill: none;
305  stroke: currentColor;
306  stroke-width: 2.6;
307  stroke-linecap: round;
308  stroke-linejoin: round;
309  stroke-dasharray: 30;
310  stroke-dashoffset: 30;
311  animation: draw 0.45s 0.15s ease forwards;
312}
313
314@keyframes draw {
315  to {
316    stroke-dashoffset: 0;
317  }
318}
319
320.t-spinner {
321  width: 18px;
322  height: 18px;
323  border: 2.5px solid color-mix(in srgb, var(--c) 25%, transparent);
324  border-top-color: var(--c);
325  border-radius: 50%;
326  animation: spin 0.75s linear infinite;
327}
328
329@keyframes spin {
330  to {
331    transform: rotate(360deg);
332  }
333}
334
335.t-body {
336  padding-top: 4px;
337  min-width: 0;
338}
339
340.t-title {
341  font-size: 0.95rem;
342  font-weight: 600;
343}
344
345.t-msg {
346  margin-top: 3px;
347  color: var(--muted);
348  font-size: 0.86rem;
349  line-height: 1.45;
350}
351
352.t-action {
353  margin-top: 10px;
354  padding: 6px 12px;
355  border: 0;
356  border-radius: 8px;
357  background: var(--text);
358  color: #fff;
359  font-size: 0.82rem;
360  font-weight: 600;
361  cursor: pointer;
362}
363
364.t-close {
365  display: grid;
366  place-items: center;
367  width: 26px;
368  height: 26px;
369  border: 0;
370  border-radius: 8px;
371  background: none;
372  color: var(--muted);
373  font-size: 0.85rem;
374  cursor: pointer;
375}
376
377.t-close:hover {
378  background: #f1f2f7;
379  color: var(--text);
380}
381
382/* Time left — runs as a CSS animation, pauses while you hover the stack */
383.t-progress {
384  position: absolute;
385  left: 0;
386  right: 0;
387  bottom: 0;
388  height: 3px;
389  background: var(--c);
390  opacity: 0.55;
391  transform-origin: left;
392  animation: shrink var(--duration, 4000ms) linear forwards;
393}
394
395.toaster:hover .t-progress,
396.toast:focus-within .t-progress {
397  animation-play-state: paused;
398}
399
400@keyframes shrink {
401  to {
402    transform: scaleX(0);
403  }
404}
405
406/* ---------- Responsive ---------- */
407@media (max-width: 640px) {
408  .playground {
409    grid-template-columns: 1fr;
410  }
411
412  .fire {
413    grid-template-columns: repeat(2, 1fr);
414  }
415
416  .toaster.top-left,
417  .toaster.top-right,
418  .toaster.bottom-left,
419  .toaster.bottom-right {
420    left: 16px;
421    right: 16px;
422    width: auto;
423  }
424}
425
426@media (prefers-reduced-motion: reduce) {
427  .toast,
428  .t-icon svg {
429    animation: none;
430    stroke-dashoffset: 0;
431  }
432}

JavaScript

javascript
1// A tiny toast library:
2//   toast.success(title, message)   toast.error(…)   toast.warning(…)   toast.info(…)
3//   toast.promise(promise, { loading, success, error })
4//   toast.show({ type, title, message, duration, action: { label, onClick } })
5
6const toaster = document.getElementById("toaster");
7const MAX_TOASTS = 4;
8const DEFAULT_DURATION = 4000;
9const SWIPE_DISMISS = 90; // px
10
11const ICONS = {
12  success: '<svg viewBox="0 0 24 24"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>',
13  error: '<svg viewBox="0 0 24 24"><path d="M7 7l10 10M17 7L7 17"/></svg>',
14  warning: '<svg viewBox="0 0 24 24"><path d="M12 7v6M12 17v.01"/></svg>',
15  info: '<svg viewBox="0 0 24 24"><path d="M12 11v6M12 7v.01"/></svg>',
16  loading: '<span class="t-spinner"></span>',
17};
18
19const escapeHTML = (s = "") => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
20
21// Collapse the toast's height smoothly, then remove it
22const dismiss = (el) => {
23  if (!el || el.dataset.leaving) return;
24  el.dataset.leaving = "true";
25  const h = el.offsetHeight;
26  el.animate(
27    [
28      { height: `${h}px`, opacity: 1, transform: getComputedStyle(el).transform },
29      { height: "0px", opacity: 0, paddingTop: "0px", paddingBottom: "0px", marginBottom: "-10px", transform: "scale(0.94)" },
30    ],
31    { duration: 300, easing: "ease-in", fill: "forwards" },
32  ).finished.then(() => el.remove());
33};
34
35const render = (el, { type, title, message, duration, action }) => {
36  el.className = `toast ${type}`;
37  el.setAttribute("role", type === "error" ? "alert" : "status");
38  el.innerHTML = `
39    <span class="t-icon" aria-hidden="true">${ICONS[type]}</span>
40    <div class="t-body">
41      <p class="t-title">${escapeHTML(title)}</p>
42      ${message ? `<p class="t-msg">${escapeHTML(message)}</p>` : ""}
43      ${action ? `<button type="button" class="t-action">${escapeHTML(action.label)}</button>` : ""}
44    </div>
45    <button type="button" class="t-close" aria-label="Dismiss">✕</button>
46    ${type === "loading" ? "" : `<span class="t-progress" style="--duration:${duration}ms"></span>`}`;
47
48  el.querySelector(".t-close").addEventListener("click", () => dismiss(el));
49  el.querySelector(".t-progress")?.addEventListener("animationend", () => dismiss(el));
50  if (action) {
51    el.querySelector(".t-action").addEventListener("click", () => {
52      action.onClick?.();
53      dismiss(el);
54    });
55  }
56};
57
58// Swipe sideways (mouse or touch) to throw a toast away
59const enableSwipe = (el) => {
60  let startX = null;
61  let dx = 0;
62  el.addEventListener("pointerdown", (e) => {
63    if (e.target.closest("button")) return;
64    startX = e.clientX;
65    el.setPointerCapture(e.pointerId);
66    el.style.transition = "none";
67  });
68  el.addEventListener("pointermove", (e) => {
69    if (startX === null) return;
70    dx = e.clientX - startX;
71    el.style.transform = `translateX(${dx}px)`;
72    el.style.opacity = String(1 - Math.min(Math.abs(dx) / 220, 0.8));
73  });
74  const end = () => {
75    if (startX === null) return;
76    startX = null;
77    el.style.transition = "transform 0.25s, opacity 0.25s";
78    if (Math.abs(dx) > SWIPE_DISMISS) {
79      el.style.transform = `translateX(${Math.sign(dx) * 420}px)`;
80      el.style.opacity = "0";
81      setTimeout(() => dismiss(el), 200);
82    } else {
83      el.style.transform = "";
84      el.style.opacity = "";
85    }
86    dx = 0;
87  };
88  el.addEventListener("pointerup", end);
89  el.addEventListener("pointercancel", end);
90};
91
92const show = (options) => {
93  const opts = { type: "info", duration: DEFAULT_DURATION, ...options };
94  const el = document.createElement("div");
95  render(el, opts);
96  enableSwipe(el);
97  toaster.prepend(el);
98
99  // Keep the stack short — oldest go first
100  const live = [...toaster.querySelectorAll(".toast:not([data-leaving])")];
101  live.slice(MAX_TOASTS).forEach(dismiss);
102  return el;
103};
104
105const toast = {
106  show,
107  success: (title, message, extra) => show({ type: "success", title, message, ...extra }),
108  error: (title, message, extra) => show({ type: "error", title, message, ...extra }),
109  warning: (title, message, extra) => show({ type: "warning", title, message, ...extra }),
110  info: (title, message, extra) => show({ type: "info", title, message, ...extra }),
111  dismiss,
112  // One toast that goes loading → success / error with the promise
113  promise(promise, { loading, success, error }) {
114    const el = show({ type: "loading", title: loading });
115    promise.then(
116      (value) => render(el, { type: "success", title: typeof success === "function" ? success(value) : success, duration: DEFAULT_DURATION }),
117      (err) => render(el, { type: "error", title: typeof error === "function" ? error(err) : error, duration: DEFAULT_DURATION }),
118    );
119    return promise;
120  },
121};
122
123// ---------- Demo buttons ----------
124const fakeUpload = () => new Promise((resolve, reject) => setTimeout(() => (Math.random() > 0.2 ? resolve(3) : reject()), 1800));
125
126const demos = {
127  success: () => toast.success("Changes saved", "Your profile is up to date."),
128  error: () => toast.error("Payment failed", "Your card was declined. Try another card."),
129  warning: () => toast.warning("Storage almost full", "You've used 92% of your 5 GB."),
130  info: () => toast.info("New version available", "Refresh to get the latest features."),
131  promise: () =>
132    toast.promise(fakeUpload(), {
133      loading: "Uploading 3 files…",
134      success: (n) => `${n} files uploaded`,
135      error: "Upload failed — please retry",
136    }),
137  undo: () =>
138    toast.show({
139      type: "info",
140      title: "Message archived",
141      message: "It's been moved to your archive.",
142      duration: 6000,
143      action: { label: "Undo", onClick: () => toast.success("Restored", "The message is back in your inbox.") },
144    }),
145};
146
147document.querySelectorAll("[data-demo]").forEach((btn) => {
148  btn.addEventListener("click", () => demos[btn.dataset.demo]());
149});
150
151// ---------- Position picker ----------
152document.querySelectorAll("[data-pos]").forEach((btn) => {
153  btn.addEventListener("click", () => {
154    document.querySelectorAll("[data-pos]").forEach((b) => b.setAttribute("aria-checked", b === btn));
155    toaster.className = `toaster ${btn.dataset.pos}`;
156    toast.info("Moved here", `Toasts now appear ${btn.getAttribute("aria-label").toLowerCase()}.`);
157  });
158});
159
160// A friendly hello on load
161setTimeout(() => toast.success("Welcome!", "Click the buttons to try every toast."), 500);

Built by MetaSkillGroup — copy it, use it, make it yours.

Features

  • toast.success / error / warning / info one-liners

  • toast.promise() — loading → success or error in one toast

  • Action buttons like Undo

  • Six positions (top/bottom × left/center/right)

  • Progress bar shows time left and pauses on hover

  • Swipe sideways to dismiss (mouse and touch)

  • Stack limit, smooth collapse and aria-live announcements

Built With

🌐

HTML5

🎨

CSS3

⚡

JavaScript

Compatibility

Browsers
ChromeEdgeFirefoxSafariOperaBraveArcVivaldi
Devices
DesktopTabletMobile
Frameworks
Vanilla JSHTML/CSS

❓ FAQs

Call toast.success("Title", "Message") — or error, warning, info. For full control use toast.show({ type, title, message, duration, action }).

Pass any promise: toast.promise(fetch(url), { loading: "Saving…", success: "Saved!", error: "Failed" }). The same toast changes when the promise settles.

4 seconds by default (DEFAULT_DURATION). Pass duration in milliseconds to change it per toast. Hovering the stack pauses all timers.

MetaSkillGroup

Written by

MetaSkillGroup

October 15, 2026

Need it built for you?

Built to the same standard as our client work.

If nothing here fits exactly, tell us what you need — our core developers build it. Requirements call first, a written SRS before any code, and a price agreed up front.

Toast Notifications with Undo (HTML CSS JS) | MetaSkillGroup