Global System with Alpine.store
Toast notifications are a standard UI pattern that shows up in every project. With Alpine.store you can build a global toast system that any component can trigger, complete with auto-dismiss, animations, queuing, and full accessibility, without loading an external library.
Table of Contents
- 1. Requirements for a Toast Notification System
- 2. The Notification Store: Data Structure and Actions
- 3. The Renderer Component: DOM and Animations
- 4. Notification Types: Success, Error, Warning, Info
- 5. Auto-Dismiss and Pause-on-Hover
- 6. Queuing and the Maximum Number of Simultaneous Toasts
- 7. Accessibility: ARIA Live Regions and Keyboard
- 8. Integration into Hyvä and Magento 2
- 9. Comparison: Custom System vs. Libraries
- 10. Summary
- 11. FAQ
1. Requirements for a Toast Notification System
A toast notification system has to meet several requirements at once. It must be global: any component on the page should be able to trigger notifications without communicating directly with the renderer. It must support multiple types: success, error, warning, and info. Each notification should disappear automatically after a configurable time, but users should be able to pause it by hovering or focusing it. Several notifications should be visible at the same time, but the maximum count should be configurable so the UI doesn't get overloaded. And all of this has to be accessible: screen readers should announce notifications, and keyboard users must be able to dismiss them.
The architecture for this system in Alpine.js is straightforward: a central Alpine.store manages the list of active notifications and provides actions to add new ones or remove existing ones. A renderer component with x-data reads this store and renders the toasts with x-for. It's placed once on the page, typically right before the closing </body> tag or in a layout template. Any other component on the page can then simply call $store.notifications.add(...) and the renderer takes care of the rest.
2. The Notification Store: Data Structure and Actions
The store is the heart of the system. It holds the list of active notifications as an array of objects. Each object has a unique ID (for the :key binding in x-for), the type, the message, an optional title, the duration until auto-dismiss, and a timer handle for later removal. The add() and remove() actions are the only public interface of the store.
document.addEventListener('alpine:init', () => {
Alpine.store('notifications', {
items: [],
maxVisible: 5,
defaultDuration: 4000,
_nextId: 1,
/**
* Add a notification to the stack.
* @param {string} message
* @param {'success'|'error'|'warning'|'info'} type
* @param {object} options
*/
add(message, type = 'info', options = {}) {
const id = this._nextId++;
const duration = options.duration ?? this.defaultDuration;
const notification = {
id,
message,
type,
title: options.title ?? null,
duration,
persistent: options.persistent ?? false,
paused: false,
_timer: null
};
// Enforce max visible: remove oldest if over limit
if (this.items.length >= this.maxVisible) {
const oldest = this.items[0];
clearTimeout(oldest._timer);
this.items.shift();
}
this.items.push(notification);
// Auto-dismiss after duration (unless persistent)
if (!notification.persistent && duration > 0) {
notification._timer = setTimeout(() => {
this.remove(id);
}, duration);
}
return id;
},
remove(id) {
const index = this.items.findIndex(n => n.id === id);
if (index === -1) return;
clearTimeout(this.items[index]._timer);
this.items.splice(index, 1);
},
pauseTimer(id) {
const n = this.items.find(n => n.id === id);
if (n && n._timer) {
clearTimeout(n._timer);
n.paused = true;
}
},
resumeTimer(id) {
const n = this.items.find(n => n.id === id);
if (n && n.paused && !n.persistent) {
n._timer = setTimeout(() => this.remove(id), n.duration / 2);
n.paused = false;
}
},
// Shorthand helpers
success(msg, opts = {}) { return this.add(msg, 'success', opts); },
error(msg, opts = {}) { return this.add(msg, 'error', { duration: 0, persistent: true, ...opts }); },
warning(msg, opts = {}) { return this.add(msg, 'warning', opts); },
info(msg, opts = {}) { return this.add(msg, 'info', opts); },
clearAll() {
this.items.forEach(n => clearTimeout(n._timer));
this.items = [];
}
});
});
3. The Renderer Component: DOM and Animations
The renderer component reads the store and renders the toasts. It's placed once on the page and has no business logic of its own, it is purely responsible for the display. x-for iterates over $store.notifications.items, x-transition handles the enter and leave animations. The container's position is set with absolute CSS classes, typically in the bottom right corner of the screen.
<!-- Notification renderer: place once at the end of the body -->
<!-- No own x-data needed: the store is accessible directly via $store -->
<div
x-data
class="fixed bottom-4 right-4 z-50 flex flex-col gap-3 w-full max-w-sm"
role="region"
aria-label="Notifications"
aria-live="polite"
aria-atomic="false"
>
<template x-for="notification in $store.notifications.items" :key="notification.id">
<div
x-show="true"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 scale-95"
x-transition:enter-end="opacity-100 translate-y-0 scale-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 scale-100"
x-transition:leave-end="opacity-0 translate-y-2 scale-95"
@mouseenter="$store.notifications.pauseTimer(notification.id)"
@mouseleave="$store.notifications.resumeTimer(notification.id)"
@focusin="$store.notifications.pauseTimer(notification.id)"
@focusout="$store.notifications.resumeTimer(notification.id)"
class="relative flex items-start gap-3 p-4 rounded-xl shadow-xl border"
:class="{
'bg-emerald-50 border-emerald-200 text-emerald-900': notification.type === 'success',
'bg-red-50 border-red-200 text-red-900': notification.type === 'error',
'bg-amber-50 border-amber-200 text-amber-900': notification.type === 'warning',
'bg-blue-50 border-blue-200 text-blue-900': notification.type === 'info'
}"
role="alert"
:aria-label="notification.type + ': ' + notification.message"
>
<!-- Icon area -->
<div class="flex-shrink-0 mt-0.5">
<!-- Success icon, error icon, etc. via :class -->
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<p x-show="notification.title" x-text="notification.title"
class="font-semibold text-sm mb-0.5"></p>
<p x-text="notification.message" class="text-sm"></p>
</div>
<!-- Close button -->
<button
@click="$store.notifications.remove(notification.id)"
class="flex-shrink-0 opacity-50 hover:opacity-100 transition-opacity"
:aria-label="'Close notification: ' + notification.message"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"/>
</svg>
</button>
</div>
</template>
</div>
4. Notification Types: Success, Error, Warning, Info
Four standard types cover the majority of use cases. Success toasts confirm successful actions: product added to cart, form submitted, settings saved. They disappear automatically after a short time. Error toasts show critical errors and are persistent by default, the user has to actively dismiss them because the error message stays relevant. Warning toasts inform about non-critical issues that deserve attention but don't require immediate action. Info toasts deliver neutral information.
The distinction between types happens exclusively in the store, the renderer component reacts to the type value with the corresponding CSS classes. That makes it easy to change the visual design without touching the store logic. Each type can be triggered through its own shorthand method: $store.notifications.success('Cart updated'), $store.notifications.error('Server unreachable').
5. Auto-Dismiss and Pause-on-Hover
Auto-dismiss is implemented with setTimeout, whose handle is stored in the notification object. This makes it possible to stop the timer when needed, for example when the user hovers the mouse over the toast. The pauseTimer() method clears the timer, resumeTimer() sets a new timer with half the original duration (since the user has already read the toast). This pattern respects user interaction and prevents notifications from disappearing while the user is reading them.
Error notifications are persistent by default (duration: 0, persistent: true), because error messages often provide context needed for a required action. This behavior can be overridden per call: $store.notifications.error('Error', { persistent: false, duration: 5000 }). The clearAll() method clears all timers before emptying the array, to avoid memory leaks caused by uncancelled timers.
6. Queuing and the Maximum Number of Simultaneous Toasts
When several actions trigger notifications in quick succession, for example when adding multiple products to the cart, the toast stack can flood the UI. The maxVisible property limits the number of simultaneous toasts. If a new notification exceeds the limit, the oldest one gets removed. This behavior is deliberately not implemented as a true queue system (where excess toasts would wait), because stale notifications are rarely useful once the user hasn't seen them right away.
// Usage from any component, no direct component access needed
// Simple usage in the HTML template
// <button @click="$store.notifications.success('Saved!')">Save</button>
// Usage inside an async function
document.addEventListener('alpine:init', () => {
Alpine.data('checkoutForm', () => ({
submitting: false,
formData: { name: '', email: '', address: '' },
async submit() {
this.submitting = true;
try {
const res = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.formData)
});
if (!res.ok) {
const err = await res.json();
// Persistent error toast, stays until manually dismissed
Alpine.store('notifications').error(
err.message || 'Order failed. Please try again.',
{ title: 'Submission error' }
);
return;
}
const data = await res.json();
// Success toast with a short duration
Alpine.store('notifications').success(
`Order #${data.orderId} completed successfully!`,
{ title: 'Order confirmed', duration: 6000 }
);
// Reset the form
this.formData = { name: '', email: '', address: '' };
} catch (networkError) {
Alpine.store('notifications').error(
'Network error. Please check your internet connection.',
{ title: 'Connection problem', persistent: true }
);
} finally {
this.submitting = false;
}
}
}));
});
7. Accessibility: ARIA Live Regions and Keyboard
Toast notifications are challenging from an accessibility standpoint: they appear outside the current focus context and need to be announced by screen readers without interrupting the user. The solution is an aria-live="polite" region that announces new content as soon as the user pauses. The role="alert" on each toast marks it for screen readers as an important notification. The close button needs a descriptive aria-label that includes the notification message, so users know what they're closing.
For toasts, keyboard navigation mainly means the close button must be reachable with Tab. Since toasts aren't part of the normal document flow, the container still needs to be reachable within the focus flow. The role="region" on the container with an aria-label lets screen reader users jump straight to the notification region. Pausing the timer on @focusin makes sure notifications don't disappear while the user is focusing the close button.
8. Integration into Hyvä and Magento 2
In Hyvä projects, a system like this replaces or complements the built-in Hyvä messages. The store is registered in a .phtml template in the <head>. The renderer component is extracted into its own template, included at the end of the body through layout XML. Existing Hyvä components that emit messages internally can communicate with the store through the custom event system: they fire a CustomEvent, and the store listens for that event.
For Magento 2 specific messages, such as cart updates or form errors, the store can also be driven through PHP-generated inline scripts. The pattern is: PHP renders the message data as JSON, the inline script reads the data and calls the store action. Every inline script block is always followed by <?= $hyvaCsp->registerInlineScript() ?>.
9. Comparison: Custom System vs. Libraries
Libraries like Toastify.js, Notyf, or SweetAlert2 offer ready-made toast systems. The advantage of a custom system built with Alpine.store is full integration into Alpine.js reactivity without extra dependencies or bundle size, direct control over styling with Tailwind CSS, and compatibility with Magento 2's CSP policy.
| Aspect | Alpine.store (custom) | Toastify.js | SweetAlert2 |
|---|---|---|---|
| Bundle size | 0 KB extra | ~5 KB | ~47 KB |
| Tailwind integration | Native | CSS override needed | Complex CSS override |
| Alpine reactivity | Full | Adapter needed | Adapter needed |
| CSP compatible | Yes (inline script) | Mostly yes | Requires unsafe-eval |
| Maintenance effort | Custom code | Library updates | Library updates |
10. Summary
A global toast notification system with Alpine.store covers all the typical requirements without an external library: auto-dismiss with pause-on-hover, four notification types, configurable persistence, queuing via maxVisible, and full accessibility with ARIA live regions. The architecture separates concerns cleanly: the store holds the logic and the state, the renderer component is purely responsible for the display, and any other component can trigger notifications without importing the store directly.
In Hyvä projects, this pattern is especially valuable because it integrates seamlessly into the existing Alpine.js infrastructure and stays CSP compliant. The shorthand methods success(), error(), warning(), and info() keep usage minimal: a single line of code is enough to trigger a meaningful notification with a title, type, and duration.
Toast Notifications with Alpine.store: The Key Takeaways
Store Structure
Array of notification objects with ID, type, message, timer handle. Actions: add, remove, pauseTimer, resumeTimer, clearAll.
Auto-Dismiss
Store the setTimeout handle in the object. Pause on hover/focus with clearTimeout/setTimeout. Errors persistent by default.
Accessibility
Container: aria-live="polite". Each toast: role="alert". Close button: aria-label with message text. Pause timer on focus.
Hyvä Integration
Register the store in alpine:init. Renderer as its own template at the end of the body. $hyvaCsp->registerInlineScript() after every script.
Mironsoft
Alpine.js, Hyvä Themes, and Magento 2 Frontend Development
UI components for your Hyvä project?
We build accessible, performant UI components with Alpine.js and Tailwind CSS: toast systems, modals, dropdowns, and more, CSP compliant and without external dependencies.
UI Components
Toasts, modals, dropdowns, and forms, accessible and CSP compliant
Hyvä Integration
Seamless integration into Hyvä layouts with layout XML and phtml templates
Accessibility
WCAG 2.1 AA compliant components with ARIA and keyboard navigation
11. FAQ: Alpine.js Toast Notifications
1Trigger a toast from any component?
$store.notifications.success('...'). JavaScript: Alpine.store('notifications').error('...'). No import needed.2Why are errors persistent by default?
{ persistent: false, duration: 5000 }.3How many toasts at once?
maxVisible in the store (default: 5). When exceeded, the oldest toast is removed.4How does pause-on-hover work?
@mouseenter: clearTimeout (stop the timer). @mouseleave: new timer with half the duration. Also applies to @focusin/@focusout.5Accessible for screen readers?
aria-live="polite". Toast: role="alert". Close button: aria-label with message text. Pause timer on focus.6Add custom toast types?
type string to add(). Add the matching CSS class for the new type in the renderer.7Prevent timer leaks?
clearTimeout(n._timer) when removing. clearAll() stops all timers before the array is emptied.8Hyvä integration?
alpine:init inside .phtml. $hyvaCsp->registerInlineScript() after the script. Renderer at the end of the body via layout XML.9Toast with an action button?
action: { label, callback }. Renderer renders the button and calls the callback on click.10Renderer position in Hyvä?
</body> tag, in its own .phtml template, included via default.xml at the end of the body.