Skip to content
Menu

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.

Scene

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

HTML — code.html
<!-- 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>
Alpine — alpine.js
/*
 * 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}`
        },
    }))
})