Designing a Toast Notification System with Alpine.js
Building a production notification system with Alpine.js: animation management, severity levels, action buttons, auto-dismiss, and LiveView integration.
Every web application needs a notification system. Most get it wrong by treating notifications as simple text banners. A production notification system needs severity classification, action buttons, animation management, auto-dismiss with pause-on-hover, keyboard accessibility, and integration with server-side events. Here is how we built ours with Alpine.js and Phoenix LiveView.
The Alpine.js Component
The notification system is a single Alpine.js component that manages a notification queue:
document.addEventListener('alpine:init', () => {
Alpine.data'notificationSystem', () =>{
:,
: 5,
: 5000,
add(notification) {
const id = crypto.randomUUID;
const entry = {
id,
: notification.type || 'info',
: notification.title || '',
: notification.message,
: notification.actions ||,
: notification.duration || this.defaultDuration,
: notification.persistent || false,
: notification.dismissable !== false,
: Date.now,
: false,
: false
};
this.notifications.pushentry;
this.enforceMaxVisible;
// Trigger enter animation on next frame
requestAnimationFrame() => {
entry.visible = true;
};
if!entry.persistent {
this.scheduleAutoDismissentry;
}
},
dismiss(id) {
const entry = this.notifications.findn => n.id === id;
if!entry return;
entry.visible = false;
setTimeout() => {
this.notifications = this.notifications.filtern => n.id !== id;
}, 300; // Match CSS transition duration
},
enforceMaxVisible() {
whilethis.notifications.length > this.maxVisible {
this.dismissthis.notifications0.id;
}
},
scheduleAutoDismiss(entry) {
const check = () => {
ifentry.paused {
setTimeoutcheck, 100;
return;
}
const elapsed = Date.now - entry.timestamp;
ifelapsed >= entry.duration {
this.dismissentry.id;
} else {
setTimeoutcheck, entry.duration - elapsed;
}
};
setTimeoutcheck, entry.duration;
},
pauseDismiss(id) {
const entry = this.notifications.findn => n.id === id;
ifentry entry.paused = true;
},
resumeDismiss(id) {
const entry = this.notifications.findn => n.id === id;
ifentry {
entry.paused = false;
entry.timestamp = Date.now; // Reset timer
}
}
};
});Severity Levels
Four severity levels map to distinct visual treatments using Tailwind classes:
getTypeClasses(type) {
const classes = {
: {
: 'bg-green-50 border-green-200 dark:bg-green-900/30 dark:border-green-700',
: 'text-green-600 dark:text-green-400',
: 'text-green-800 dark:text-green-200'
},
: {
: 'bg-red-50 border-red-200 dark:bg-red-900/30 dark:border-red-700',
: 'text-red-600 dark:text-red-400',
: 'text-red-800 dark:text-red-200'
},
: {
: 'bg-yellow-50 border-yellow-200 dark:bg-yellow-900/30 dark:border-yellow-700',
: 'text-yellow-600 dark:text-yellow-400',
: 'text-yellow-800 dark:text-yellow-200'
},
: {
: 'bg-blue-50 border-blue-200 dark:bg-blue-900/30 dark:border-blue-700',
: 'text-blue-600 dark:text-blue-400',
: 'text-blue-800 dark:text-blue-200'
}
};
return classestype || classes.info;
}Animation System
Notifications enter from the right and exit by fading out. The animation is CSS-driven with Alpine.js controlling the state transitions:
<template x-for="notification in notifications" :key="notification.id">
<div
x-show="notification.visible"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-x-8"
x-transition:enter-end="opacity-100 translate-x-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 translate-x-0"
x-transition:leave-end="opacity-0 translate-x-8"
@mouseenter="pauseDismiss(notification.id)"
@mouseleave="resumeDismiss(notification.id)"
:class="getTypeClasses(notification.type).container"
class="pointer-events-auto w-full max-w-sm rounded-lg border p-4 shadow-lg"
role="alert"
:aria-live="notification.type === 'error' ? 'assertive' : 'polite'"
>
<div class="flex items-start">
<div class="flex-shrink-0" x-html="getIcon(notification.type)"></div>
<div class="ml-3 flex-1">
<p x-show="notification.title"
x-text="notification.title"
:class="getTypeClasses(notification.type).title"
class="text-sm font-medium"></p>
<p x-text="notification.message"
class="mt-1 text-sm text-gray-600 dark:text-gray-300"></p>
<div x-show="notification.actions.length > 0" class="mt-3 flex gap-2">
<template x-for="action in notification.actions">
<button @click="action.handler(); dismiss(notification.id)"
x-text="action.label"
class="text-sm font-medium text-blue-600 hover:text-blue-500">
</button>
</template>
</div>
</div>
<button x-show="notification.dismissable"
@click="dismiss(notification.id)"
class="ml-4 flex-shrink-0 text-gray-400 hover:text-gray-600">
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" 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" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
</template>Action Buttons
Notifications can include action buttons that provide contextual operations:
// Example: Error notification with retry action
notificationSystem.add({
: 'error',
: 'OSINT Query Failed',
: 'The ARES registry returned a timeout. The query has been queued for retry.',
: true,
: [
{
: 'Retry Now',
handler: () => { liveSocket.execJSdocument.body, 'retry-osint-query'; }
},
{
: 'View Details',
handler: () => { window.location.href = '/hub/osint/runs/latest'; }
}
]
});LiveView Integration
The critical integration point: server-side events triggering client-side notifications. LiveView pushes events, and a global listener dispatches them to the Alpine.js component:
# Server side - in any LiveView
defp notify_success(socket, message) do
push_event(socket, "notification", %{
type: "success",
message: message,
duration: 3000
})
end
defp notify_error(socket, message) do
push_event(socket, "notification", %{
type: "error",
title: "Error",
message: message,
persistent: true
})
end// Client side - global event listener
document.addEventListener('phx:notification', (event) => {
const system = Alpine.$data
document.querySelector'[x-data*="notificationSystem"]'
;
ifsystem {
system.addevent.detail;
}
});
This bidirectional integration means any LiveView in the application can trigger notifications without importing any JavaScript. The server pushes a "notification" event with a payload, and the global listener routes it to the Alpine.js component.
Keyboard Accessibility
The notification system supports keyboard interaction:
- Escape: Dismisses the most recent notification.
- Tab: Moves focus between action buttons within notifications.
- aria-live regions: Screen readers announce notifications based on severity (assertive for errors, polite for info).
init() {
document.addEventListener'keydown', (e) => {
ife.key === 'Escape' && this.notifications.length > 0 {
const latest = this.notificationsthis.notifications.length - 1;
iflatest.dismissable {
this.dismisslatest.id;
}
}
};
}Reduced Motion Support
For users who prefer reduced motion (a system accessibility setting), animations are replaced with instant show/hide:
@media (prefers-reduced-motion: reduce) {
[x-transition] {
transition-duration: 0.01ms !important;
}
}Queue Management
Under heavy event load (such as during a bulk OSINT operation that produces many results), the notification system can be overwhelmed. The enforceMaxVisible function caps the visible count at 5, dismissing the oldest notification when a new one arrives. For bulk operations, we aggregate notifications:
defp notify_bulk_progress(socket, completed, total) do
push_event(socket, "notification", %{
type: "info",
title: "Bulk Operation",
message: "#{}/#{} items processed",
duration: 2000,
group: "bulk_progress" # Client-side deduplication key
})
end
The client-side component recognizes the group key and replaces existing notifications with the same group instead of adding new ones:
add(notification) {
ifnotification.group {
const existing = this.notifications.findn => n.group === notification.group;
ifexisting {
existing.message = notification.message;
existing.timestamp = Date.now;
return;
}
}
// ... normal add logic
}
This notification system has been running in production across all Prismatic dashboards since March 2026. It handles approximately 50-100 notifications per user session with zero memory leaks and consistent animation performance across Chrome, Firefox, and Safari.
Dive deeper into the Prismatic Platform -- explore our architecture, agents, and epistemic framework.