BACK

Feb 5, 2025

0 words
0 m

Building a Toast Notification System from Scratch

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; /* info default */
  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:

/* bottom left variant */
#toast-root.bottom-left { top: auto; right: auto; bottom: 1rem; left: 1rem; }
/* bottom right */
#toast-root.bottom-right { top: auto; bottom: 1rem; right: 1rem; }
/* top left */
#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; // visible cap
  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);

    // pause on hover or focus
    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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;','\'':'&#39;'}[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.

// extend show API
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(); // message -> timestamp
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;
}

// before queue.push
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 /* sticky while loading */ });
  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;
  }
}

// usage
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.

import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';

type ToastType = 'info' | 'success' | 'warning' | 'error';

type ToastItem = { id: number; title?: string; message: string; type: ToastType; duration: number; dismissible: boolean };

type ToastOptions = Partial<Omit<ToastItem, 'id'>> & { message: string };

type ToastHandle = { close: () => void };

type ToastContextValue = { show: (opts: ToastOptions) => ToastHandle };

const ToastCtx = createContext<ToastContextValue | null>(null);

export function ToastProvider({ children }: { children: React.ReactNode }){
  const [items, setItems] = useState<ToastItem[]>([]);
  const idRef = useRef(1);
  const maxVisible = 4;

  const show = useCallback((opts: ToastOptions): ToastHandle => {
    const id = idRef.current++;
    const item: ToastItem = {
      id,
      title: opts.title,
      message: opts.message,
      type: opts.type ?? 'info',
      duration: opts.duration ?? 3000,
      dismissible: opts.dismissible ?? true,
    };
    setItems(prev => {
      const next = [...prev, item];
      return next.length > maxVisible ? next.slice(next.length - maxVisible) : next;
    });

    let timer: number | undefined;
    if (item.duration > 0) {
      timer = window.setTimeout(() => close(id), item.duration);
    }

    function close(targetId = id){
      setItems(cur => cur.filter(t => t.id !== targetId));
      if (timer) window.clearTimeout(timer);
    }

    return { close };
  }, []);

  const value = useMemo(() => ({ show }), [show]);
  return (
    <ToastCtx.Provider value={value}>
      {children}
      <ToastContainer items={items} onClose={(id)=>setItems(cur=>cur.filter(t=>t.id!==id))} />
    </ToastCtx.Provider>
  );
}

export function useToast(){
  const ctx = useContext(ToastCtx);
  if (!ctx) throw new Error('useToast must be used inside ToastProvider');
  return ctx;
}

function ToastContainer({ items, onClose }: { items: ToastItem[]; onClose: (id: number)=>void }){
  return (
    <div id="toast-root" aria-live="polite" aria-atomic="true" style={{ position:'fixed', top:16, right:16, display:'flex', flexDirection:'column', gap:8, zIndex:9999 }}>
      {items.map(item => (
        <div key={item.id} role="status" aria-live="polite" className={`toast toast--${item.type} show`} style={{ display:'grid', gridTemplateColumns:'1fr auto', gap:8, minWidth:240, maxWidth:360, padding:'12px 14px', borderRadius:8, color:'#fff', background: bg(item.type), boxShadow:'0 4px 12px rgba(0,0,0,0.25)' }}>
          <div>
            {item.title && <p className="toast__title" style={{ margin:0, fontWeight:600 }}>{item.title}</p>}
            <p className="toast__msg" style={{ margin:'2px 0 0 0', opacity:.95 }}>{item.message}</p>
          </div>
          {item.dismissible && (
            <button aria-label="Close" onClick={()=>onClose(item.id)} className="toast__close" style={{ background:'transparent', border:0, color:'inherit', cursor:'pointer' }}>×</button>
          )}
        </div>
      ))}
    </div>
  );
}

function bg(type: ToastType){
  switch(type){
    case 'success': return '#16a34a';
    case 'warning': return '#d97706';
    case 'error':   return '#dc2626';
    default:        return '#2563eb';
  }
}

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.

Taseen Tanvir

1:12:28 UTC

Create a free website with Framer, the website builder loved by startups, designers and agencies.