{"slug": "build-a-toast-notification-system-using-only-css-and-javascript", "title": "Build a Toast/Notification System using only CSS and JavaScript", "summary": "A developer detailed the process of building a toast notification system using only CSS and JavaScript, highlighting the pitfalls of a naive implementation. The final solution uses a single container with ARIA roles for accessibility, CSS animations for smooth transitions, and JavaScript for managing timers, hover pauses, and a maximum visible toast count.", "body_md": "I built my first \"quick\" toast component in about twenty minutes. Then I spent the next two days fixing it, because it turns out the twenty-minute version breaks the second you do anything realistic with it — trigger three toasts back to back, hover over one while it's about to disappear, resize the window, or turn on VoiceOver. What looks like the simplest UI pattern in your whole app is actually a pile of small decisions wearing a trench coat.\n\nHere's the version I landed on after fixing all of that, and the reasoning behind each piece, so you don't have to rediscover it the hard way.\n\nA few things I only figured out by shipping a broken version first:\n\nNone of these need a library to fix. They need about sixty lines of JS and a bit of CSS that actually thinks about what happens when a real user touches it.\n\nOne container. Toasts get appended into it dynamically — you're not hand-writing each one.\n\n```\n<div class=\"toast-region\" id=\"toastRegion\" role=\"region\" aria-label=\"Notifications\"></div>\n```\n\nEach toast gets its own status role. This part matters more than the animation does:\n\n```\n<div class=\"toast\" role=\"status\" aria-live=\"polite\">\n  <p class=\"toast-message\">Changes saved</p>\n  <button class=\"toast-close\" aria-label=\"Dismiss notification\">&times;</button>\n</div>\n```\n\n`role=\"status\"`\n\nwith `aria-live=\"polite\"`\n\nlets a screen reader announce it without cutting off whatever the person was already listening to. Save `role=\"alert\"`\n\nfor stuff that's actually urgent — a failed payment, a dropped connection. If you use `alert`\n\nfor \"Saved!\" too, people using a screen reader learn to tune your app out within a week.\n\n```\n.toast-region {\n  position: fixed;\n  bottom: 1.5rem;\n  right: 1.5rem;\n  display: flex;\n  flex-direction: column-reverse;\n  gap: 0.6rem;\n  z-index: 1000;\n  pointer-events: none;\n}\n\n.toast {\n  pointer-events: auto;\n  /* ... */\n}\n```\n\nThat `pointer-events: none`\n\n/ `auto`\n\nsplit fixed a bug that took me embarrassingly long to track down: the toast container spans a big chunk of the screen, and without this, the invisible gaps between toasts were eating clicks meant for buttons underneath. Set it once and forget about it.\n\n`column-reverse`\n\nis the other detail doing quiet work — new toasts append at the bottom of the DOM but visually land closest to where you're already looking, without you having to manually reorder anything.\n\n```\n.toast {\n  animation: toast-in 0.25s ease-out;\n}\n\n@keyframes toast-in {\n  from { opacity: 0; transform: translateY(12px) scale(0.95); }\n  to   { opacity: 1; transform: translateY(0) scale(1); }\n}\n\n.toast.toast--leaving {\n  animation: toast-out 0.2s ease-in forwards;\n}\n\n@keyframes toast-out {\n  to { opacity: 0; transform: translateX(40px); }\n}\n```\n\nStick to `opacity`\n\nand `transform`\n\nand this stays smooth even with several toasts animating in and out at once — it never touches layout. I gave the exit a horizontal slide instead of mirroring the entrance, mostly because it made it obvious at a glance which toasts were arriving and which were leaving, without me having to think about it consciously.\n\nThis is the part that actually matters. It handles the timer, the hover pause, the cap on how many toasts can pile up, and cleanup.\n\n``` js\nconst region = document.getElementById('toastRegion');\nconst MAX_VISIBLE = 4;\nconst DEFAULT_DURATION = 4000;\n\nfunction showToast(message, { duration = DEFAULT_DURATION, urgent = false, variant = '' } = {}) {\n  const toast = document.createElement('div');\n  toast.className = `toast${variant ? ` toast--${variant}` : ''}`;\n  toast.setAttribute('role', urgent ? 'alert' : 'status');\n  toast.setAttribute('aria-live', urgent ? 'assertive' : 'polite');\n\n  toast.innerHTML = `\n    <p class=\"toast-message\"></p>\n    <button class=\"toast-close\" aria-label=\"Dismiss notification\">&times;</button>\n  `;\n  toast.querySelector('.toast-message').textContent = message; // textContent, not innerHTML — see note below\n\n  region.appendChild(toast);\n  enforceMaxVisible();\n  makeSwipeable(toast);\n\n  let timer = startTimer(toast, duration);\n  toast.addEventListener('mouseenter', () => clearTimeout(timer));\n  toast.addEventListener('mouseleave', () => { timer = startTimer(toast, duration); });\n  toast.querySelector('.toast-close').addEventListener('click', () => dismiss(toast));\n\n  return toast;\n}\n\nfunction startTimer(toast, duration) {\n  return setTimeout(() => dismiss(toast), duration);\n}\n\nfunction dismiss(toast) {\n  toast.classList.add('toast--leaving');\n  toast.addEventListener('animationend', () => toast.remove(), { once: true });\n}\n\nfunction enforceMaxVisible() {\n  const toasts = region.querySelectorAll('.toast:not(.toast--leaving)');\n  if (toasts.length > MAX_VISIBLE) dismiss(toasts[0]);\n}\n```\n\nI went back and forth on whether to pause-and-resume the timer accurately on hover, versus just clearing it and starting a fresh one. Accurate pause/resume means tracking elapsed time by hand, which is more code for a difference nobody will ever notice on a four-second toast. Clear and restart won.\n\nThe message gets set with `textContent`\n\n, not shoved into the `innerHTML`\n\ntemplate string. If a toast message ever comes from user input — \"Message sent to @username\" style stuff — `innerHTML`\n\nthere is a stored XSS hole waiting to happen. Habit worth keeping even when today's messages are all hardcoded strings.\n\nNobody wants to hunt for a tiny × button on their phone.\n\n``` js\nfunction makeSwipeable(toast) {\n  let startX = 0, currentX = 0, dragging = false;\n\n  toast.addEventListener('pointerdown', (e) => {\n    dragging = true;\n    startX = e.clientX;\n    toast.style.transition = 'none';\n  });\n\n  toast.addEventListener('pointermove', (e) => {\n    if (!dragging) return;\n    currentX = e.clientX - startX;\n    toast.style.transform = `translateX(${currentX}px)`;\n    toast.style.opacity = String(1 - Math.min(Math.abs(currentX) / 200, 0.8));\n  });\n\n  toast.addEventListener('pointerup', () => {\n    dragging = false;\n    toast.style.transition = '';\n    if (Math.abs(currentX) > 100) {\n      dismiss(toast);\n    } else {\n      toast.style.transform = '';\n      toast.style.opacity = '';\n    }\n    currentX = 0;\n  });\n}\n```\n\nPointer Events instead of separate touch/mouse listeners means this works the same on a laptop trackpad, a phone, and a stylus without three copies of the same logic. The 100px threshold is just enough that scrolling past a toast doesn't accidentally dismiss it.\n\n```\n:root {\n  --toast-bg: #1f2430;\n  --toast-text: #ffffff;\n}\n\n[data-theme=\"dark\"] {\n  --toast-bg: #2a2f3d;\n  --toast-text: #e8eaed;\n}\n\n.toast--success { --toast-bg: #1b7a4d; }\n.toast--error   { --toast-bg: #a3312f; }\n.toast--warning { --toast-bg: #a6741b; }\n```\n\n`showToast('Saved!', { variant: 'success' })`\n\nand the class gets applied automatically from the function above. Swap the theme by flipping `data-theme`\n\non `<html>`\n\n, same as any other component built on custom properties.\n\n**Don't put the only \"Undo\" button inside a toast that auto-dismisses.** I've seen this shipped more than once — the toast disappears in four seconds and the undo option disappears with it. If someone reads slower than that, or has a motor impairment that makes clicking a small button in a hurry difficult, the action is just gone. Either give undo toasts a much longer timer or don't auto-dismiss them at all.\n\n**Respect reduced motion:**\n\n```\n@media (prefers-reduced-motion: reduce) {\n  .toast,\n  .toast.toast--leaving {\n    animation: none;\n  }\n  .toast.toast--leaving {\n    opacity: 0;\n  }\n}\n```\n\n**Don't move focus to a toast.** It's showing up alongside whatever the user is doing, not interrupting it. Stealing focus is the fastest way to make someone lose their place mid-form.\n\n**Watch what happens under toast spam.** The `MAX_VISIBLE`\n\ncap above handles the obvious case — five things happening at once shouldn't produce five toasts fighting for space. I'd also debounce identical messages if your app can realistically fire the same one twice (retried network requests are the usual culprit) — update the existing toast's timer instead of stacking a duplicate on top of it.\n\nA toast system that feels solid isn't about the animation — it's stacking that doesn't fight itself, a timer that backs off when someone's actually reading, dismissal that feels like it was on purpose, and a live region that announces without shouting. All of that fits in about sixty lines of JavaScript and some `position: fixed`\n\n. No dependency required.\n\n**Please check the full working code here** ⬇️\n\n[jsfiddle.net/d7wxrmv9/1](https://jsfiddle.net/artclick/91u3ngm8/)\n\nWe're ArtClick, a web development agency based in Kyoto. We build company websites, WordPress sites, and custom systems — with a focus on sites that are fast, well-designed, and easy to maintain long-term. Learn more at [https://artclickdev.com/](https://artclickdev.com/)", "url": "https://wpnews.pro/news/build-a-toast-notification-system-using-only-css-and-javascript", "canonical_source": "https://dev.to/_artclick/build-a-toastnotification-system-using-only-css-and-javascript-975", "published_at": "2026-08-12 09:04:14+00:00", "updated_at": "2026-08-12 09:16:11.613695+00:00", "lang": "en", "topics": ["developer-tools"], "entities": [], "alternates": {"html": "https://wpnews.pro/news/build-a-toast-notification-system-using-only-css-and-javascript", "markdown": "https://wpnews.pro/news/build-a-toast-notification-system-using-only-css-and-javascript.md", "text": "https://wpnews.pro/news/build-a-toast-notification-system-using-only-css-and-javascript.txt", "jsonld": "https://wpnews.pro/news/build-a-toast-notification-system-using-only-css-and-javascript.jsonld"}}