Toast Notifications in Vanilla JavaScript That Screen Readers Can Hear
Build toast notifications with plain JavaScript: auto-dismiss, hover to pause, close button, stacking limit and accessible announcements. Code and live demo.

A toast is a small message that appears, tells you something happened, and goes away. "Settings saved." "Could not reach the server." They are easy to build, and easy to build badly: they vanish before anyone reads them, pile up when a button is clicked quickly, and say nothing at all to someone using a screen reader.
This version fixes those three things in under forty lines.
What you will build
Three buttons that create success, error and info toasts. Each disappears after four seconds, pauses while the pointer is over it, can be dismissed with a button, and the stack never grows beyond four.
The frame is small, so open the demo in its own tab to try it at full size.
The markup
<!-- In the <head>: <link rel="stylesheet" href="/style.css"> -->
<main class="page">
<h1>Notifications</h1>
<p>Click a button. Hover a toast to keep it on screen.</p>
<div class="buttons">
<button class="btn" type="button" data-toast="success" data-message="Settings saved.">Success</button>
<button class="btn" type="button" data-toast="error" data-message="Could not reach the server.">Error</button>
<button class="btn" type="button" data-toast="info" data-message="A new version is available.">Info</button>
</div>
</main>
<div class="toasts" id="toasts" aria-live="polite"></div>
<script src="/script.js" defer></script>
The page contains one empty container, #toasts, and it is marked aria-live="polite". That detail matters more than it looks. A live region has to exist in the page before its content changes, so assistive technology is already watching it when a toast arrives. Creating the container at the same moment as the message is a common reason announcements are missed.
The JavaScript
const stack = document.querySelector("#toasts");
const MAX_TOASTS = 4;
function showToast(message, type = "info", duration = 4000) {
const toast = document.createElement("div");
toast.className = `toast toast--${type}`;
// The container is a polite live region. Errors also get role="alert" so they are announced at once.
if (type === "error") toast.setAttribute("role", "alert");
const text = document.createElement("span");
text.textContent = message;
const close = document.createElement("button");
close.type = "button";
close.className = "toast__close";
close.setAttribute("aria-label", "Dismiss notification");
close.textContent = "\u00d7";
toast.append(text, close);
stack.append(toast);
// Keep the stack short if someone clicks fast.
while (stack.children.length > MAX_TOASTS) stack.firstElementChild.remove();
const dismiss = () => toast.remove();
let timer = setTimeout(dismiss, duration);
toast.addEventListener("mouseenter", () => clearTimeout(timer));
toast.addEventListener("mouseleave", () => {
timer = setTimeout(dismiss, 1500);
});
close.addEventListener("click", dismiss);
}
document.querySelectorAll("[data-toast]").forEach((button) => {
button.addEventListener("click", () => {
showToast(button.dataset.message, button.dataset.toast);
});
});
showToast builds the element with createElement and textContent, so a message containing angle brackets is shown as text and never interpreted as HTML. It then appends the toast, trims the stack, and starts a timer.
Errors get role="alert", which interrupts the screen reader immediately. Everything else relies on the polite container, which waits until the user is idle. That split keeps routine confirmations from talking over someone while still making failures impossible to miss.
The CSS
:root {
--ink: #1f1d1a;
--muted: #6c675f;
--line: #dfddd6;
--accent: #2f46c8;
--paper: #f7f7f5;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
color: var(--ink);
background: var(--paper);
}
.page {
max-width: 560px;
margin: 0 auto;
padding: 2rem 1.25rem;
}
.page h1 {
margin: 0 0 0.5rem;
font-size: 1.4rem;
}
.page p {
color: var(--muted);
}
.buttons {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
}
.btn {
padding: 0.55rem 1rem;
font: inherit;
color: var(--ink);
background: #fff;
border: 1px solid var(--line);
border-radius: 8px;
cursor: pointer;
}
.btn:focus-visible,
.toast__close:focus-visible {
outline: 3px solid var(--accent);
outline-offset: 2px;
}
.toasts {
position: fixed;
right: 1rem;
bottom: 1rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
width: min(360px, calc(100vw - 2rem));
}
.toast {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.8rem 0.9rem 0.8rem 1rem;
color: #fff;
background: #2b3040;
border-left: 4px solid #8fa2ff;
border-radius: 8px;
box-shadow: 0 10px 24px rgba(15, 18, 24, 0.25);
animation: toast-in 0.25s ease-out;
}
.toast--success {
border-left-color: #5cc98a;
}
.toast--error {
border-left-color: #ff7a80;
}
.toast__close {
padding: 0 0.4rem;
font-size: 1.3rem;
line-height: 1;
color: inherit;
background: none;
border: 0;
border-radius: 4px;
cursor: pointer;
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(12px);
}
}
@media (prefers-reduced-motion: reduce) {
.toast {
animation: none;
}
}
The stack is position: fixed in the corner, with gap handling the spacing, so toasts need no margins of their own. On a narrow screen the width is capped at calc(100vw - 2rem), which keeps the whole toast on screen. The slide-in is a short keyframe animation that is switched off under prefers-reduced-motion.
Common problems and how to fix them
Toasts pile up when someone clicks fast
Ten clicks make ten toasts, and they cover the interface. The while loop after append removes the oldest ones once the stack passes MAX_TOASTS. Four is a reasonable ceiling.
The message disappears before it is read
A fixed four seconds is short for a long sentence, and shorter still for people who read slowly. The demo pauses the timer on mouseenter and restarts a shorter one on mouseleave. Give longer messages a longer duration, and never use a toast for something the visitor has to act on.
Screen readers stay silent
Check three things. The live region must already be in the HTML, not added together with the toast. The message must be real text inside it. And error messages need role="alert", because polite announcements can be delayed.
The close button is too small to tap
A bare "x" with no padding is a tiny target. The demo gives it horizontal padding and a larger font, and aria-label="Dismiss notification" gives it a real name for screen readers, since "×" alone is read out inconsistently.
Toasts cover important controls
A corner toast can sit on top of a floating button or a form's submit row on small screens. If that happens, move the stack to the top, or add bottom padding to the page while a toast is visible.
A reminder about scope
Toasts are for short, non-critical feedback. A failed payment, a destructive action that needs confirming, or anything that blocks the next step belongs in the page itself or in a dialog, where it stays until the person deals with it.