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.
| # | Level | Title | Message | Time |
|---|---|---|---|---|
| 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 });
}
});
});