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.
Here'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.
The problems nobody mentions until you hit them
A few things I only figured out by shipping a broken version first:
- New toasts kept shoving old ones around instead of stacking cleanly.
- The empty space around a toast was blocking clicks on stuff underneath it. Nobody warns you about this one.
- Hovering over a toast to read it didn't stop the timer, so it'd vanish mid-sentence.
- Screen readers either ignored the toast completely or, worse, barked every single one like an emergency alert.
- Trigger five toasts fast and the whole stack turns into a flickering mess.
None 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.
Markup
One container. Toasts get appended into it dynamically — you're not hand-writing each one.
<div class="toast-region" id="toastRegion" role="region" aria-label="Notifications"></div>
Each toast gets its own status role. This part matters more than the animation does:
<div class="toast" role="status" aria-live="polite">
<p class="toast-message">Changes saved</p>
<button class="toast-close" aria-label="Dismiss notification">×</button>
</div>
role="status" with aria-live="polite" lets a screen reader announce it without cutting off whatever the person was already listening to. Save role="alert" for stuff that's actually urgent — a failed payment, a dropped connection. If you use alert for "Saved!" too, people using a screen reader learn to tune your app out within a week.
Positioning — the click-blocking bug
.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;
/* ... */
}
That pointer-events: none / auto split 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.
column-reverse is 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.
Animating in and out
.toast {
animation: toast-in 0.25s ease-out;
}
@keyframes toast-in {
from { opacity: 0; transform: translateY(12px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.toast.toast--leaving {
animation: toast-out 0.2s ease-in forwards;
}
@keyframes toast-out {
to { opacity: 0; transform: translateX(40px); }
}
Stick to opacity and transform and 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.
The JS
This is the part that actually matters. It handles the timer, the hover pause, the cap on how many toasts can pile up, and cleanup.
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">×</button>
`;
toast.querySelector('.toast-message').textContent = message; // textContent, not innerHTML — see note below
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) {
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]);
}
I 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.
The message gets set with textContent, not shoved into the innerHTML template string. If a toast message ever comes from user input — "Message sent to @username" style stuff — innerHTML there is a stored XSS hole waiting to happen. Habit worth keeping even when today's messages are all hardcoded strings.
Letting people swipe it away
Nobody wants to hunt for a tiny × button on their phone.
function makeSwipeable(toast) {
let startX = 0, currentX = 0, dragging = false;
toast.addEventListener('pointerdown', (e) => {
dragging = true;
startX = e.clientX;
toast.style.transition = 'none';
});
toast.addEventListener('pointermove', (e) => {
if (!dragging) return;
currentX = e.clientX - startX;
toast.style.transform = `translateX(${currentX}px)`;
toast.style.opacity = String(1 - Math.min(Math.abs(currentX) / 200, 0.8));
});
toast.addEventListener('pointerup', () => {
dragging = false;
toast.style.transition = '';
if (Math.abs(currentX) > 100) {
dismiss(toast);
} else {
toast.style.transform = '';
toast.style.opacity = '';
}
currentX = 0;
});
}
Pointer 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.
Colors, dark mode, variants
:root {
--toast-bg: #1f2430;
--toast-text: #ffffff;
}
[data-theme="dark"] {
--toast-bg: #2a2f3d;
--toast-text: #e8eaed;
}
.toast--success { --toast-bg: #1b7a4d; }
.toast--error { --toast-bg: #a3312f; }
.toast--warning { --toast-bg: #a6741b; }
showToast('Saved!', { variant: 'success' }) and the class gets applied automatically from the function above. Swap the theme by flipping data-theme on <html>, same as any other component built on custom properties.
Things I'd tell someone about before they ship this
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.
Respect reduced motion:
@media (prefers-reduced-motion: reduce) {
.toast,
.toast.toast--leaving {
animation: none;
}
.toast.toast--leaving {
opacity: 0;
}
}
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.
Watch what happens under toast spam. The MAX_VISIBLE cap 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.
That's really it
A 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. No dependency required.
Please check the full working code here ⬇️
jsfiddle
We'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/
Top comments (0)