Toasts give users quick feedback without blocking their work. Save completed, copy to clipboard, error while saving, network restored - these should be small, clear, and temporary. In this guide, you will build a robust, accessible toast system from scratch. We will start simple, then add features until this matches production needs.
What you will build:
A vanilla JS toast system with stacking, theming, queueing, and limits.
Keyboard and screen reader friendly behavior.
Motion preferences and reduced-motion support.
Optional TypeScript and React version with Context and hooks.
A final checklist you can reuse.
No dependencies. All code is copy paste ready.
When to use a toast, and when not to
Use a toast for short, transient info:
Confirmation after a safe action. Example: profile saved.
Information that does not require a choice. Example: copied link.
Non-critical errors that are easy to retry. Example: temporary network hiccup.
Avoid toasts for:
Destructive or risky actions that need confirmation. Use a modal.
Critical errors that require user action. Use inline errors or a dialog.
Persistent system notices. Use a banner.
Requirements and design choices
Functional goals:
Appear quickly, disappear automatically.
Stack neatly without covering primary controls.
Limit the number of visible toasts.
Allow manual dismiss with a close button.
Pause auto-dismiss on hover and focus.
Announce messages to assistive tech.
Respect reduced-motion.
Work with and without JavaScript enhancements.
HTML scaffold
Add this near the end of <body>
.
<div id="toast-root" aria-live="polite" aria-atomic="true"></div>
aria-live="polite"
announces messages to screen readers without stealing focus.
aria-atomic="true"
ensures the entire message is read.
You can place multiple roots for different corners if you need positions.
Base CSS
Keep it small, readable, and themeable.
#toast-root {
position: fixed;
top: 1rem;
right: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
z-index: 9999;
}
.toast {
display: grid;
grid-template-columns: 1fr auto;
align-items: start;
gap: 8px;
min-width: 240px;
max-width: 360px;
padding: 12px 14px;
border-radius: 8px;
color: #fff;
font: 14px/1.4 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
background: #2563eb;
opacity: 0;
transform: translateX(100%);
transition: opacity .25s ease, transform .25s ease;
}
.toast.show { opacity: 1; transform: translateX(0); }
.toast__title { font-weight: 600; margin: 0; }
.toast__msg { margin: 2px 0 0 0; opacity: .95; }
.toast__close {
background: transparent;
border: 0;
color: inherit;
cursor: pointer;
padding: 2px 4px;
border-radius: 6px;
}
.toast__close:focus { outline: 2px solid rgba(255,255,255,.6); outline-offset: 2px; }
.toast--success { background: #16a34a; }
.toast--error { background: #dc2626; }
.toast--warning { background: #d97706; }
.toast--info { background: #2563eb; }
@media (prefers-reduced-motion: reduce) {
.toast { transition: none; transform: none; opacity: 1; }
}
Positions you may want:
#toast-root.bottom-left { top: auto; right: auto; bottom: 1rem; left: 1rem; }
#toast-root.bottom-right { top: auto; bottom: 1rem; right: 1rem; }
#toast-root.top-left { left: 1rem; right: auto; }
Vanilla JavaScript API
We will expose a small API: toast.show(opts)
returns a handle with close()
.
<script>
(function(){
const root = document.getElementById('toast-root');
if (!root) throw new Error('Missing #toast-root');
const MAX_VISIBLE = 4;
const queue = [];
let visible = 0;
function createToast({ title, message, type = 'info', duration = 3000, dismissible = true }) {
const el = document.createElement('div');
el.className = `toast toast--${type}`;
el.setAttribute('role', 'status');
el.setAttribute('aria-live', 'polite');
const closeBtn = dismissible ? `<button class="toast__close" aria-label="Close" title="Close">×</button>` : '';
el.innerHTML = `
<div>
${title ? `<p class="toast__title">${escapeHTML(title)}</p>` : ''}
${message ? `<p class="toast__msg">${escapeHTML(message)}</p>` : ''}
</div>
${closeBtn}
`;
const btn = el.querySelector('.toast__close');
const state = { closed: false };
function removeToast() {
if (state.closed) return; state.closed = true;
el.classList.remove('show');
el.addEventListener('transitionend', () => { el.remove(); visible--; pump(); }, { once: true });
}
if (btn) btn.addEventListener('click', removeToast);
let timerId; let remaining = duration; let start;
function startTimer() {
if (!duration) return;
start = Date.now();
timerId = setTimeout(removeToast, remaining);
}
function pauseTimer() {
if (!duration) return;
clearTimeout(timerId);
remaining -= Date.now() - start;
}
el.addEventListener('mouseenter', pauseTimer);
el.addEventListener('mouseleave', startTimer);
el.addEventListener('focusin', pauseTimer);
el.addEventListener('focusout', startTimer);
return { el, show(){ root.appendChild(el); requestAnimationFrame(()=>{ el.classList.add('show'); startTimer(); }); }, close: removeToast };
}
function escapeHTML(s){ return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"','\'':'''}[c])); }
function pump(){
if (visible >= MAX_VISIBLE) return;
const next = queue.shift();
if (!next) return;
visible++;
const t = createToast(next.opts);
t.show();
next.resolve(t);
}
window.toast = {
show(opts){
return new Promise(resolve => { queue.push({ opts, resolve }); pump(); });
},
config({ maxVisible }){ if (Number.isFinite(maxVisible)) { window.toast.maxVisible = maxVisible; } }
};
})();
</script>
Usage examples:
<button onclick="toast.show({ title: 'Saved', message: 'Your profile was updated', type: 'success' })">Show success</button>
<button onclick="toast.show({ title: 'Error', message: 'Network error. Try again', type: 'error' })">Show error</button>
<button onclick="toast.show({ message: 'Copied to clipboard', type: 'info', duration: 1500 })">Short info</button>
Notes:
Messages are escaped to avoid XSS if you pass user input.
Auto dismiss pauses on hover and focus.
show
returns a handle you can close manually later.
Variants and theming
Add icons and different colors with a tiny template change. You can use emoji or inline SVG to avoid external assets.
.toast--info::before { content: 'ℹ️'; margin-right: 8px; }
.toast--success::before { content: '✅'; margin-right: 8px; }
.toast--warning::before { content: '⚠️'; margin-right: 8px; }
.toast--error::before { content: '⛔'; margin-right: 8px; }
.toast { grid-template-columns: auto 1fr auto; }
If you prefer a neutral look, keep titles, colors, and icons subtle. For error, keep contrast high.
Accessibility details
Announcements. We set role="status"
and aria-live="polite"
on each toast. Screen readers will read the full message.
Focus. Toasts do not steal focus. The close button is reachable with Tab. Focus outlines are visible.
Motion. prefers-reduced-motion
disables transforms and transitions.
Language. Do not hardcode strings if your app is translated. Provide localized text for titles and messages.
Timing. WCAG suggests users need enough time to read content. Our hover and focus pause gives control.
Optional, for high priority errors only:
<div id="toast-root-urgent" aria-live="assertive" aria-atomic="true"></div>
Use assertive sparingly so you do not interrupt other speech.
Advanced: positions and multiple roots
You may want different corners for different message types.
<div id="toast-top-right" class="top-right" aria-live="polite" aria-atomic="true"></div>
<div id="toast-bottom-left" class="bottom-left" aria-live="polite" aria-atomic="true"></div>
Then pass a rootId
option and mount there.
toast.show({ message: 'Saved', type: 'success', rootId: 'toast-bottom-left' })
You would resolve the root inside createToast
with document.getElementById(rootId) || defaultRoot
.
Advanced: deduplication and rate limits
Avoid spamming the same message repeatedly.
const recent = new Map();
const DEDUPE_MS = 1500;
function shouldShow(msg){
const now = Date.now();
const last = recent.get(msg) || 0;
if (now - last < DEDUPE_MS) return false;
recent.set(msg, now);
return true;
}
if (shouldShow(opts.message || opts.title)) { queue.push({ opts, resolve }); }
Rate limit by counting enqueues per second and dropping excess, or by collapsing into a single toast that says "5 more events".
Advanced: promise helpers
It is handy to attach toasts to async work. Show a loading toast, then replace it on success or failure.
async function withToast(promise, { loading = 'Working...', success = 'Done', error = 'Something went wrong' } = {}){
const t = await toast.show({ message: loading, type: 'info', duration: 0 });
try {
const result = await promise;
t.close();
toast.show({ message: success, type: 'success' });
return result;
} catch (e) {
t.close();
toast.show({ title: 'Error', message: error, type: 'error', dismissible: true });
throw e;
}
}
withToast(fetch('/api/save'), { loading: 'Saving...', success: 'Saved' });
Sticky toasts use duration: 0
so they only close manually or when replaced.
Testing tips
Throttle CPU and network in DevTools. Ensure animations still feel okay, and text is readable before auto-dismiss.
Navigate with keyboard only. You should be able to Tab to the close button. Focus outline must be visible.
Screen reader check. Turn on VoiceOver or NVDA. Trigger a toast and confirm it is announced once.
Reduced motion. Turn on reduced motion in OS settings. Toasts should appear without motion.
Mobile safe area. If you place toasts at the bottom on iOS, add padding for the home indicator.
React version with Context and TypeScript
This is a light but complete React implementation. It includes a provider, hook, and container. Type annotations are optional, you can drop them if you prefer JS.
Usage:
import React from 'react';
import { ToastProvider, useToast } from './toast';
function SaveButton(){
const { show } = useToast();
async function handleClick(){
const t = show({ message: 'Saving...', type: 'info', duration: 0 });
try {
await new Promise(r => setTimeout(r, 1200));
t.close();
show({ message: 'Saved', type: 'success' });
} catch {
t.close();
show({ title: 'Error', message: 'Could not save', type: 'error' });
}
}
return <button onClick={handleClick}>Save</button>;
}
export default function App(){
return (
<ToastProvider>
<SaveButton />
</ToastProvider>
);
}
SSR note for Next.js: render the provider normally. To avoid hydration flash, keep the container markup minimal. Since toasts render on user action, server and client markup will match.
Security and content safety
Escape any user controlled content before inserting into the DOM. We used escapeHTML
above.
Do not inject raw HTML into toasts unless you control all content.
Keep durations reasonable. 2 to 5 seconds is typical. Give users a close button for longer messages.
Final checklist
✅ Non-blocking, small, and readable.
✅ Dismisses automatically, and can be dismissed manually.
✅ Stacks with a visible cap, queues overflow.
✅ Pauses on hover and focus so users can read.
✅ Announces via aria-live
and respects reduced-motion.
✅ Escapes text to avoid XSS.
✅ Supports multiple variants and positions.
✅ Provides a simple API in vanilla JS and a Context hook in React.
Ship it, and reuse it across projects.