Experiment #012 · New
Toast Notification
Toasts live in a single Alpine array and render absolutely anchored to the bottom-right corner; each one derives its stack position from a translateY computed off its index, so adding or closing a toast restacks the rest with a pure transform transition. Entry and exit slide on translateX plus opacity, a scaleX progress bar drains over 3.5s in sync with a JS timer that auto-dismisses the toast unless the close button beats it. The stack is a polite aria-live status region, so screen readers announce each toast without interrupting.
Technical details
- Experiment
- FX-012
- Category
- Toasts
- Stack
- Tailwind + Alpine.js
- Difficulty
- Advanced
- Dependencies
- none
- Weight
- HTML 4.3 KB · ALPINE 2.8 KB
- Added
- 2026-08-31
- Updated
- 2026-08-31
Lab notes
The JS timeout dismisses the toast; the progress bar only visualizes it — keep the 3500ms timer and the 3.5s CSS animation in sync. The stack caps visible toasts at three and evicts the oldest; if you change the toast height, adjust the 56px stacking step in styleFor() to match (toast height + gap).
Take the code
<!-- Toast Notification — Tailwind + Alpine.js -->
<div
x-data="{
toasts: [],
counter: 0,
timers: {},
messages: ['Changes saved', 'Link copied to clipboard', 'Profile updated', 'Build finished'],
add() {
const visible = this.toasts.filter((t) => ! t.leaving)
if (visible.length >= 3) {
this.dismiss(visible[0].id)
}
const id = ++this.counter
this.toasts.push({
id,
message: this.messages[(id - 1) % this.messages.length],
shown: false,
leaving: false,
})
this.timers[id] = setTimeout(() => this.dismiss(id), 3500)
this.$nextTick(() => requestAnimationFrame(() => requestAnimationFrame(() => {
const toast = this.toasts.find((t) => t.id === id)
if (toast) {
toast.shown = true
}
})))
},
dismiss(id) {
const toast = this.toasts.find((t) => t.id === id)
if (! toast || toast.leaving) {
return
}
clearTimeout(this.timers[id])
delete this.timers[id]
toast.leaving = true
setTimeout(() => {
this.toasts = this.toasts.filter((t) => t.id !== id)
}, 300)
},
styleFor(toast, index) {
const settled = toast.shown && ! toast.leaving
const lift = (this.toasts.length - 1 - index) * 56
return `transform: translate(${settled ? '0%' : '120%'}, -${lift}px); opacity: ${settled ? 1 : 0}`
},
}"
>
<style>
@keyframes fx-toast-notification-drain {
to {
transform: scaleX(0);
}
}
.fx-toast-notification-drain {
transform-origin: left;
animation: fx-toast-notification-drain 3.5s linear forwards;
}
@media (prefers-reduced-motion: reduce) {
.fx-toast-notification-drain {
animation: none;
}
}
</style>
<button
type="button"
@click="add()"
class="rounded-full bg-zinc-900 px-6 py-2.5 text-sm font-medium text-white
transition-colors duration-150 hover:bg-zinc-700
focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-zinc-500"
>
Show toast
</button>
<!-- Toast stack: one per page, anchored to the viewport corner -->
<div role="status" aria-live="polite" class="fixed bottom-4 right-4 z-50 w-72">
<template x-for="(toast, index) in toasts" :key="toast.id">
<div
:style="styleFor(toast, index)"
class="absolute bottom-0 right-0 flex w-72 items-center gap-2.5 overflow-hidden
rounded-md border border-zinc-200 bg-white py-2.5 pl-3 pr-1.5
transition-[transform,opacity] duration-300 ease-out will-change-transform
motion-reduce:transition-none"
>
<svg class="h-4 w-4 shrink-0 text-zinc-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M20 6 9 17l-5-5" />
</svg>
<span class="min-w-0 flex-1 truncate text-sm font-medium text-zinc-900" x-text="toast.message"></span>
<button
type="button"
@click="dismiss(toast.id)"
aria-label="Dismiss notification"
class="grid h-6 w-6 shrink-0 place-items-center rounded-sm text-zinc-400
transition-colors duration-150 hover:text-zinc-900
focus-visible:outline-2 focus-visible:outline-zinc-500"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
<span class="fx-toast-notification-drain absolute inset-x-0 bottom-0 h-0.5 bg-zinc-500" aria-hidden="true"></span>
</div>
</template>
</div>
</div>
/*
* Toast Notification — reusable Alpine.js stack.
*
* Register once (before Alpine.start), then mount a single stack per page:
*
* <div
* x-data="toastStack"
* @toast.window="add($event.detail)"
* role="status"
* aria-live="polite"
* class="fixed bottom-4 right-4 z-50 w-72"
* >
* <template x-for="(toast, index) in toasts" :key="toast.id">
* <div :style="styleFor(toast, index)" class="absolute bottom-0 right-0 w-72 ...">
* <span x-text="toast.message"></span>
* <button type="button" @click="dismiss(toast.id)" aria-label="Dismiss">×</button>
* </div>
* </template>
* </div>
*
* Fire a toast from anywhere (plain JS, Livewire, another component):
*
* window.dispatchEvent(new CustomEvent('toast', { detail: 'Changes saved' }))
*
* Options: x-data="toastStack(5000, 4, 64)" → duration ms, max visible,
* stacking step in px (toast height + gap). Each toast should be
* position: absolute, anchored bottom-right, with a
* transition-[transform,opacity] and motion-reduce:transition-none.
*/
document.addEventListener('alpine:init', () => {
Alpine.data('toastStack', (duration = 3500, maxVisible = 3, step = 56) => ({
toasts: [],
counter: 0,
timers: {},
add(message) {
const visible = this.toasts.filter((t) => ! t.leaving)
if (visible.length >= maxVisible) {
this.dismiss(visible[0].id)
}
const id = ++this.counter
this.toasts.push({ id, message, shown: false, leaving: false })
this.timers[id] = setTimeout(() => this.dismiss(id), duration)
// Double rAF: let the entry styles paint before settling the toast.
this.$nextTick(() => requestAnimationFrame(() => requestAnimationFrame(() => {
const toast = this.toasts.find((t) => t.id === id)
if (toast) {
toast.shown = true
}
})))
},
dismiss(id) {
const toast = this.toasts.find((t) => t.id === id)
if (! toast || toast.leaving) {
return
}
clearTimeout(this.timers[id])
delete this.timers[id]
toast.leaving = true
// Remove after the exit transition (keep in sync with duration-300).
setTimeout(() => {
this.toasts = this.toasts.filter((t) => t.id !== id)
}, 300)
},
styleFor(toast, index) {
const settled = toast.shown && ! toast.leaving
const lift = (this.toasts.length - 1 - index) * step
return `transform: translate(${settled ? '0%' : '120%'}, -${lift}px); opacity: ${settled ? 1 : 0}`
},
}))
})