Notifications are one of those UI patterns that look simple but hide a surprising number of edge cases: enter animations, leave animations, auto-dismiss timers, accessibility attributes, and a close button that does not fight the transition. You can reach for a JavaScript component library, or you can build a solid one in about 30 lines using Tailwind CSS utility classes and Alpine.js reactive directives.
This tutorial walks through four progressive versions — a static banner, a dismissible one, one with smooth transitions, and a fully self-dismissing toast — so you understand every piece rather than just copy-pasting a black box.
Prerequisites
You need Tailwind CSS and Alpine.js available in your project. If you are prototyping, the quickest path is two script tags (no build step):
<!-- Alpine.js (load before closing body tag) -->
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>
<!-- Tailwind CSS v4 Play CDN -->
<script src="https://cdn.tailwindcss.com"></script>
If you are on a real project with a build pipeline, Tailwind v4 replaces tailwind.config.js with a CSS-first @theme block in your main stylesheet. Alpine.js is installed via npm (npm install alpinejs) and imported in your entry file. Nothing below depends on either CDN specifically — the utility classes and directives work the same either way.
Step 1: A static notification banner
Start with plain HTML and Tailwind classes. This gives you the visual foundation before adding any behavior.
<div role="alert" aria-live="polite"
class="flex items-center gap-3 rounded-lg border border-green-200
bg-green-50 px-4 py-3 text-sm text-green-800">
<svg xmlns="http://www.w3.org/2000/svg" class="size-5 shrink-0"
viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0
00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06
1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clip-rule="evenodd" />
</svg>
<p>Your changes have been saved successfully.</p>
</div>
A few things to note here. role="alert" and aria-live="polite" tell screen readers to announce the message when it appears. The shrink-0 class on the icon prevents it from squishing when the text wraps. The color scheme (green for success) follows convention — swap to yellow/amber for warnings, red for errors, blue for informational messages.
Step 2: Make it dismissible with Alpine.js
Now wire up a close button. Alpine's x-data directive turns any element into a reactive component; x-show conditionally renders based on a boolean.
<div x-data="{ show: true }">
<div x-show="show"
role="alert" aria-live="polite"
class="flex items-center gap-3 rounded-lg border border-green-200
bg-green-50 px-4 py-3 text-sm text-green-800">
<svg xmlns="http://www.w3.org/2000/svg" class="size-5 shrink-0"
viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0
00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06
1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clip-rule="evenodd" />
</svg>
<p class="flex-1">Your changes have been saved successfully.</p>
<button @click="show = false"
type="button"
class="ml-auto rounded p-1 hover:bg-green-100"
aria-label="Dismiss notification">
<svg xmlns="http://www.w3.org/2000/svg" class="size-4"
viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72
3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0
101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10
8.94 6.28 5.22z" />
</svg>
</button>
</div>
</div>
The outer div with x-data owns the component state. The close button sets show to false and x-show reacts instantly. x-show toggles display: none rather than removing the element from the DOM, which keeps the element available for the transition in the next step.
Step 3: Add enter and leave transitions
Alpine's x-transition directive hooks into x-show's visibility toggle and applies CSS classes at specific moments in the transition lifecycle. This is where Tailwind's transition utilities earn their keep.
<div x-data="{ show: true }">
<div x-show="show"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-2"
role="alert" aria-live="polite"
class="flex items-center gap-3 rounded-lg border border-green-200
bg-green-50 px-4 py-3 text-sm text-green-800">
<svg xmlns="http://www.w3.org/2000/svg" class="size-5 shrink-0"
viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0
00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06
1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clip-rule="evenodd" />
</svg>
<p class="flex-1">Your changes have been saved successfully.</p>
<button @click="show = false"
type="button"
class="ml-auto rounded p-1 hover:bg-green-100"
aria-label="Dismiss notification">
<svg xmlns="http://www.w3.org/2000/svg" class="size-4"
viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72
3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0
101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10
8.94 6.28 5.22z" />
</svg>
</button>
</div>
</div>
Here is what each transition attribute does:
x-transition:enter— the CSS transition property applied for the entire entering phase.ease-out duration-300means it decelerates over 300ms.x-transition:enter-start— the starting state, applied one frame before the element becomes visible. Here the notification starts invisible (opacity-0) and slightly above its resting position (-translate-y-2).x-transition:enter-end— the target state at the end of the enter transition. Full opacity, no vertical offset.x-transition:leave— the CSS transition for leaving.ease-in duration-200accelerates slightly faster out than in, which feels more responsive.x-transition:leave-startandx-transition:leave-end— mirror the enter states in reverse.
The result is a notification that slides down and fades in when it appears, then fades and slides back up when dismissed.
Step 4: Auto-dismiss with x-init and setTimeout
Toast notifications — the kind that appear in a corner after an action — typically dismiss themselves after a few seconds. Add x-init to run code when the component initializes, and use setTimeout to schedule the dismissal.
<div x-data="{
show: false,
init() {
this.$nextTick(() => { this.show = true })
setTimeout(() => { this.show = false }, 5000)
}
}"
class="fixed bottom-4 right-4 z-50 w-80">
<div x-show="show"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-2"
role="alert" aria-live="polite"
class="flex items-center gap-3 rounded-lg border border-green-200
bg-green-50 px-4 py-3 text-sm text-green-800 shadow-lg">
<svg xmlns="http://www.w3.org/2000/svg" class="size-5 shrink-0"
viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0
00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06
1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clip-rule="evenodd" />
</svg>
<p class="flex-1">Your changes have been saved.</p>
<button @click="show = false"
type="button"
class="ml-auto rounded p-1 hover:bg-green-100"
aria-label="Dismiss notification">
<svg xmlns="http://www.w3.org/2000/svg" class="size-4"
viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72
3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0
101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10
8.94 6.28 5.22z" />
</svg>
</button>
</div>
</div>
Two details matter here. First, show starts as false and is set to true inside $nextTick. This one-frame delay is intentional: it lets Alpine apply the enter-start classes before the element becomes visible, so the browser actually runs the transition. Without the $nextTick, the element appears at full opacity immediately because there is no starting state to transition from.
Second, the setTimeout runs in init() and fires after 5,000 milliseconds, setting show to false and triggering the leave transition. The user can still close it manually before the timer fires — the close button sets the same property.
Handling different notification types
In practice you want success, warning, and error variants without duplicating the template. One clean approach is to keep the color classes in a computed lookup inside x-data:
<div x-data="{
show: true,
type: 'error',
message: 'Something went wrong. Please try again.',
get styles() {
const map = {
success: 'border-green-200 bg-green-50 text-green-800',
warning: 'border-yellow-200 bg-yellow-50 text-yellow-800',
error: 'border-red-200 bg-red-50 text-red-800',
info: 'border-blue-200 bg-blue-50 text-blue-800',
}
return map[this.type] ?? map.info
}
}">
<div x-show="show"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-2"
:class="styles"
role="alert" aria-live="polite"
class="flex items-center gap-3 rounded-lg border px-4 py-3 text-sm">
<p class="flex-1" x-text="message"></p>
<button @click="show = false"
type="button"
class="ml-auto rounded p-1 opacity-60 hover:opacity-100"
aria-label="Dismiss notification">
<svg xmlns="http://www.w3.org/2000/svg" class="size-4"
viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72
3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0
101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10
8.94 6.28 5.22z" />
</svg>
</button>
</div>
</div>
The getter styles returns a different class string depending on type. Alpine's :class binding merges those dynamic classes with the static ones already on the element. To show a different notification, change type and message in the x-data object — or set them from a server-rendered value in your template.
A note on Tailwind's class detection
Tailwind v4 scans your source files and generates only the classes it finds. If you build the class strings dynamically by concatenating partial strings (for example 'bg-' + color + '-50'), those classes will not appear in the stylesheet. The lookup-map approach used above avoids this: each full class string is present in the source file as a literal, so the scanner picks them up correctly.
Triggering from elsewhere on the page
If you need to show the notification from a button or a form somewhere else in the DOM, Alpine's global event system is the right tool. Dispatch a custom event from anywhere and listen for it at the notification component level:
<!-- Trigger from any element -->
<button @click="$dispatch('notify', { message: 'Profile updated!', type: 'success' })"
type="button"
class="rounded bg-slate-800 px-4 py-2 text-sm text-white hover:bg-slate-700">
Save profile
</button>
<!-- Notification component -->
<div x-data="{
show: false,
message: '',
type: 'success',
open(event) {
this.message = event.detail.message
this.type = event.detail.type ?? 'info'
this.show = true
setTimeout(() => { this.show = false }, 5000)
}
}"
@notify.window="open($event)"
class="fixed bottom-4 right-4 z-50 w-80">
<div x-show="show"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-2"
role="alert" aria-live="polite"
class="flex items-center gap-3 rounded-lg border border-green-200
bg-green-50 px-4 py-3 text-sm text-green-800 shadow-lg">
<p class="flex-1" x-text="message"></p>
<button @click="show = false" type="button"
class="ml-auto rounded p-1 hover:bg-green-100"
aria-label="Dismiss notification">
<svg xmlns="http://www.w3.org/2000/svg" class="size-4"
viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72
3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0
101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10
8.94 6.28 5.22z" />
</svg>
</button>
</div>
</div>
The @notify.window modifier listens for a notify event dispatched anywhere on the page. $dispatch fires a native CustomEvent and bubbles it up to window, where the listener catches it. This pattern keeps your notification component truly standalone — it does not need to know anything about the rest of the page.
Wrapping up
With about 30 lines of Alpine.js and a handful of Tailwind utility classes you have a production-quality notification component: dismissible, animated, accessible, type-aware, and triggerable from anywhere on the page. The approach intentionally avoids any component framework — it works in plain HTML, in an Astro component, or alongside whatever server-rendering setup you already have. The same pattern scales from a single inline success banner to a full toast queue with no additional dependencies.