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
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
- Google Fonts — Rubik + JetBrains Mono https://fonts.googleapis.com/css2?family=Rubik:wght@400;500;600;700&family=JetBrains+Mono:wght@500&display=swap
Already linked inside the component's files — no extra setup needed.
Desktop Preview
HTML
html1<!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 & 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
css1/* 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
javascript1// 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
❓ 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.

Written by
MetaSkillGroup
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.