Feature 316 Feedback

Toast Notification ข้อความแจ้งเตือนชั่วคราว

Small messages that slide in, stack, pause on hover and close themselves; server events are also stored as notifications.

Live demo

Everything here talks to the real Spring Boot + SQL Server back end.

Fire a toast

Toasts from the server

The server saves the message in the notification table, then returns it. Clearing uses Undo: the DELETE runs only after the toast closes.

#LevelTitleMessageTime
No notifications yet.

What to notice

  • One container with aria-live=polite announces new toasts to screen readers.
  • Timers pause while the pointer is over a toast.
  • Undo works by delaying the DELETE until the toast has closed.

Try it

Source code

7 parts, read from the files in this project
src/main/java/com/study/uiux/web/DemoApi.java POST /api/toasts: the server decides the text, the browser shows it
@PostMapping("/toasts")
public Notification toast(@Valid @RequestBody ToastRequest request) {
    return notifications.send(request.level(), request.title(), request.message());
}
src/main/java/com/study/uiux/service/NotificationService.java Save the notification, then return it for the toast
public Notification send(String level, String title, String message) {
    if (!LEVELS.contains(level)) throw ApiException.badRequest("Unknown level: " + level);
    return repository.save(new Notification(level, title, message));
}
src/main/resources/db/migration/V1__schema.sql notification: every toast the server sends is also stored here
CREATE TABLE notification (
    id         INT IDENTITY(1,1) NOT NULL CONSTRAINT pk_notification PRIMARY KEY,
    level      NVARCHAR(10)  NOT NULL,
    title      NVARCHAR(100) NOT NULL,
    message    NVARCHAR(300) NOT NULL,
    created_at DATETIME2(0)  NOT NULL CONSTRAINT df_notification_created DEFAULT SYSUTCDATETIME(),
    CONSTRAINT ck_notification_level CHECK (level IN (N'success', N'info', N'warning', N'error'))
);
src/main/resources/templates/layout.html One live region holds every toast
<div class="toast-stack" id="toasts" data-pos="bottom-right" aria-live="polite" aria-relevant="additions"></div>
src/main/resources/static/css/app.css Toasts: slide in, stack, a timer line that pauses on hover
.toast-stack { position: fixed; z-index: 250; display: flex; flex-direction: column; gap: .6rem; width: min(24rem, calc(100vw - 2rem)); pointer-events: none; }
.toast-stack[data-pos="bottom-right"] { right: 1rem; bottom: 1rem; flex-direction: column-reverse; }
.toast-stack[data-pos="top-right"] { right: 1rem; top: calc(var(--topbar-h) + .75rem); }
.toast-stack[data-pos="top-center"] { left: 50%; transform: translateX(-50%); top: calc(var(--topbar-h) + .75rem); }
.toast-stack[data-pos="bottom-center"] { left: 50%; transform: translateX(-50%); bottom: 1rem; flex-direction: column-reverse; }
.toast {
  --tone: var(--info); pointer-events: auto; display: grid; grid-template-columns: auto 1fr auto; gap: .2rem .75rem; align-items: start;
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-lg);
  padding: .85rem .85rem .95rem 1rem; position: relative; overflow: hidden; animation: toast-in .35s var(--ease);
}
.toast.success { --tone: var(--success); } .toast.warning { --tone: var(--warning); } .toast.error { --tone: var(--danger); }
.toast::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--tone); }
.toast-icon { color: var(--tone); margin-top: .1rem; }
.toast-icon .i { width: 1.3rem; height: 1.3rem; }
.toast-title { font-weight: 700; font-size: .9rem; }
.toast-msg { color: var(--text-2); font-size: .84rem; grid-column: 2; margin-top: -.1rem; }
.toast-actions { grid-column: 2; display: flex; gap: .4rem; margin-top: .45rem; }
.toast-close { grid-column: 3; grid-row: 1; width: 1.8rem; height: 1.8rem; margin: -.3rem -.3rem 0 0; }
.toast-timer { position: absolute; left: 0; bottom: 0; height: 3px; width: 100%; background: var(--tone); opacity: .55; transform-origin: left; animation: toast-timer linear forwards; }
.toast:hover .toast-timer, .toast:focus-within .toast-timer { animation-play-state: paused; }
.toast.leaving { animation: toast-out .25s ease forwards; }
@keyframes toast-in { from { opacity: 0; transform: translateY(12px) scale(.97); } }
@keyframes toast-out { to { opacity: 0; transform: translateX(30px); } }
@keyframes toast-timer { from { transform: scaleX(1); } to { transform: scaleX(0); } }
src/main/resources/static/js/core.js toast(): build, show, pause on hover, close when the timer line ends
const TOAST_ICONS = { success: 'check-circle', info: 'info', warning: 'alert', error: 'x-circle' };
function toast(opts) {
  const o = typeof opts === 'string' ? { message: opts } : opts;
  const type = o.type || 'info';
  const stack = $('#toasts');
  const el = document.createElement('div');
  el.className = `toast ${type}`;
  el.setAttribute('role', type === 'error' ? 'alert' : 'status');
  el.innerHTML = `
    <span class="toast-icon">${icon(TOAST_ICONS[type] || 'info')}</span>
    <div class="toast-title">${esc(o.title || type[0].toUpperCase() + type.slice(1))}</div>
    <button class="icon-btn toast-close" type="button" aria-label="Close notification">${icon('x')}</button>
    ${o.message ? `<div class="toast-msg">${esc(o.message)}</div>` : ''}
    ${o.action ? `<div class="toast-actions"><button class="btn btn-xs btn-soft" type="button">${o.action.icon ? icon(o.action.icon) : ''}${esc(o.action.label)}</button></div>` : ''}
    <span class="toast-timer"></span>`;
  const duration = o.duration ?? (type === 'error' ? 7000 : 4500);
  const timer = $('.toast-timer', el);
  if (duration > 0) timer.style.animationDuration = duration + 'ms'; else timer.remove();

  let closed = false;
  const close = (reason) => {
    if (closed) return;
    closed = true;
    el.classList.add('leaving');
    setTimeout(() => el.remove(), reducedMotion() ? 0 : 250);
    if (o.onClose) o.onClose(reason);
  };
  timer.addEventListener('animationend', () => close('timeout')); // CSS pauses this on hover
  $('.toast-close', el).addEventListener('click', () => close('closed'));
  if (o.action) {
    $('.toast-actions button', el).addEventListener('click', () => { o.action.onClick?.(); close('action'); });
  }
  stack.appendChild(el);
  while (stack.children.length > 5) stack.firstElementChild.remove(); // keep the stack short
  return { close, element: el };
}
src/main/resources/static/js/demos.js Undo: hide the rows now, send the DELETE only if the toast closes without Undo
$('#clearToasts').addEventListener('click', () => {
  const saved = $('#notifRows').innerHTML;
  render([]);
  toast({
    type: 'info', title: 'History cleared', message: 'Press Undo within 6 seconds to keep it.', duration: 6000,
    action: { label: 'Undo', icon: 'undo', onClick: () => { $('#notifRows').innerHTML = saved; } },
    onClose: async (reason) => {
      if (reason === 'action') return;           // undone: nothing to delete
      await api('/api/notifications', { method: 'DELETE' });
      toast({ type: 'success', title: 'Deleted', message: 'DELETE FROM notification ran on the server.', duration: 2500 });
    }
  });
});

Are you sure?

Rename

↑↓ moveEnter openEsc close