Onboarding modals have a bad reputation — usually because they are over-engineered. A full React component tree, a state management library, and three npm packages, just to show a user four welcome screens. Alpine.js was built for exactly this use case: sprinkling interactivity onto server-rendered HTML without a build pipeline. Pair it with Tailwind CSS v4 and you get a polished, accessible modal in a single file.
This guide walks through a complete multi-step onboarding modal from scratch. By the end you will have a modal that fades in on page load, steps through multiple screens, traps focus for accessibility, and exits cleanly. All the code is copy-pasteable and works with Tailwind v4's CSS-first configuration and a CDN-loaded Alpine.js.
What you need
No bundler is required for this tutorial. The only two dependencies are loaded from a CDN:
- Tailwind CSS v4 — via the standalone CDN script (
https://cdn.tailwindcss.com— note this pulls the Play CDN build, which is fine for development and prototyping) - Alpine.js v3 — via
https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.jswith thedeferattribute
If you are working inside an Astro project with a proper build pipeline, install both packages through npm and import them normally. The directive syntax is identical either way.
The HTML shell
Start with a minimal page. Alpine must load before it can process your markup, so place the <script> tag in the <head> with defer. Tailwind's CDN build auto-detects utility classes at runtime, so no configuration file is needed for this example.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Onboarding Modal</title>
<!-- Tailwind CSS v4 Play CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Alpine.js v3 (defer is required) -->
<script
defer
src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"
></script>
<style>
[x-cloak] { display: none !important; }
</style>
</head>
<body class="bg-gray-100 min-h-screen">
<!-- modal markup goes here -->
</body>
</html>
The [x-cloak] rule in the <style> block is important. Alpine processes your markup after the page renders, which means a split second where a modal you intended to be hidden flashes visible. The x-cloak attribute hides any element until Alpine has fully initialized and removed the attribute itself.
The Alpine data model
Alpine's x-data directive defines a reactive data object scoped to the element it sits on. For a multi-step modal you need three pieces of state:
open— whether the modal is visiblestep— which onboarding screen is active (1-indexed)totalSteps— the total number of steps (used to show progress and disable the Next button at the end)
x-data="{
open: true,
step: 1,
totalSteps: 3,
next() { if (this.step < this.totalSteps) this.step++ },
prev() { if (this.step > 1) this.step-- },
close() { this.open = false }
}"
Methods live inside the same object. next() and prev() guard against stepping out of range. close() is a one-liner that sets open to false, triggering the leave transition automatically.
The backdrop
A modal needs a backdrop — a semi-transparent overlay that dims the page behind it and, on click, closes the modal. With Alpine and Tailwind this is a single <div>:
<div
x-show="open"
x-cloak
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-black/50 z-40"
@click="close()"
aria-hidden="true"
></div>
The six x-transition:* directives give Alpine explicit control over the enter and leave animation. On enter, the backdrop fades from fully transparent to 50% black over 200 ms. On leave, it fades back out in 150 ms. Alpine removes the element from view (via display: none) only after the leave transition completes, so there is no jarring flash.
The modal panel
The modal itself is a separate element that sits above the backdrop. It gets its own transition — a fade combined with a subtle scale — which is a common pattern that feels snappy without being distracting:
<div
x-show="open"
x-cloak
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@keydown.escape.window="close()"
>
<div class="bg-white rounded-2xl shadow-xl w-full max-w-md">
<!-- step content goes here -->
</div>
</div>
A few things worth noting here. The outer div is a full-screen flex container that centers the inner panel. The role="dialog" and aria-modal="true" attributes tell screen readers that this is a dialog and that the content underneath it is inert. The @keydown.escape.window listener fires close() when the user presses Escape — Alpine's event modifier .window attaches the listener to the window object rather than the element itself, so it fires even if focus has moved inside the panel.
The step content
Inside the white panel, each step is a <div> controlled by x-show="step === N". Alpine hides all but the active one. No routing, no component tree, no framework:
<div class="p-8">
<!-- Progress bar -->
<div class="mb-6">
<div class="flex justify-between text-xs text-gray-400 mb-1">
<span x-text="`Step ${step} of ${totalSteps}`"></span>
<button @click="close()" class="text-gray-400 hover:text-gray-600"
aria-label="Close">×</button>
</div>
<div class="h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
class="h-full bg-indigo-500 rounded-full transition-all duration-300"
:style="`width: ${(step / totalSteps) * 100}%`"
></div>
</div>
</div>
<!-- Step 1 -->
<div x-show="step === 1">
<h2 id="modal-title" class="text-xl font-semibold text-gray-900 mb-2">
Welcome aboard
</h2>
<p class="text-gray-500 text-sm leading-relaxed">
This quick setup takes about two minutes and helps us tailor
everything to how you work.
</p>
</div>
<!-- Step 2 -->
<div x-show="step === 2">
<h2 id="modal-title" class="text-xl font-semibold text-gray-900 mb-2">
Tell us about your business
</h2>
<label class="block text-sm text-gray-700 mb-1" for="business-name">
Business name
</label>
<input
id="business-name"
type="text"
placeholder="Acme Plumbing"
class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm
focus:outline-none focus:ring-2 focus:ring-indigo-400"
/>
</div>
<!-- Step 3 -->
<div x-show="step === 3">
<h2 id="modal-title" class="text-xl font-semibold text-gray-900 mb-2">
You're all set
</h2>
<p class="text-gray-500 text-sm leading-relaxed">
Your workspace is ready. Head to the dashboard to explore everything
that's available to you.
</p>
</div>
<!-- Navigation -->
<div class="flex justify-between mt-8">
<button
@click="prev()"
:disabled="step === 1"
class="text-sm text-gray-500 hover:text-gray-700 disabled:opacity-30
disabled:cursor-not-allowed transition-opacity"
>
← Back
</button>
<button
@click="step === totalSteps ? close() : next()"
class="bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium
px-5 py-2 rounded-lg transition-colors"
>
<span x-text="step === totalSteps ? 'Get started' : 'Next'"></span>
</button>
</div>
</div>
The progress bar width is driven by an inline :style binding. Alpine evaluates the expression and sets it as a real CSS style attribute. The transition-all duration-300 Tailwind classes on the bar make it animate smoothly as you advance through steps. The Back button uses Tailwind's disabled: variant to dim itself when on step one — :disabled="step === 1" sets the HTML disabled attribute, and Tailwind's disabled:opacity-30 picks it up without any extra JavaScript.
Putting it all together
Wrap the backdrop and the modal panel in a single element that carries the x-data declaration. Both children share that same reactive scope:
<div
x-data="{
open: true,
step: 1,
totalSteps: 3,
next() { if (this.step < this.totalSteps) this.step++ },
prev() { if (this.step > 1) this.step-- },
close() { this.open = false }
}"
>
<!-- Backdrop -->
<div
x-show="open"
x-cloak
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-black/50 z-40"
@click="close()"
aria-hidden="true"
></div>
<!-- Panel -->
<div
x-show="open"
x-cloak
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@keydown.escape.window="close()"
>
<div class="bg-white rounded-2xl shadow-xl w-full max-w-md">
<!-- all the step content from the previous section -->
</div>
</div>
</div>
Triggering the modal from a button
If you want the modal to start closed and open on user action, change the initial value to open: false and add a trigger button anywhere inside the same x-data scope:
<button
@click="open = true; step = 1"
class="bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium
px-5 py-2 rounded-lg transition-colors"
>
Start onboarding
</button>
Resetting step = 1 at open time ensures the modal always starts from the beginning if the user closes it partway through and re-opens it.
A note on focus trapping
The aria-modal="true" attribute hints to screen readers that content outside the dialog is inert, but it does not physically trap keyboard focus. For a fully accessible implementation, Alpine's official Focus plugin provides an x-trap directive that handles this automatically:
<!-- Add the Focus plugin after Alpine core -->
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/[email protected]/dist/cdn.min.js"></script>
<!-- Add x-trap to the panel element -->
<div ... x-trap="open" ...>
With x-trap="open" on the panel, Alpine will move focus to the first focusable element inside the modal when it opens, and prevent Tab from escaping it until the modal closes. This is the right behaviour for a dialog — without it, a keyboard user can Tab out of the modal and interact with the dimmed page behind it.
Using Tailwind v4's CSS-first theming
If you are working in a project with a proper build pipeline (Astro, Vite, etc.) rather than the Play CDN, Tailwind v4 moves all configuration into your CSS file using the @theme directive. If your brand color is not indigo, swap it out in one place rather than doing a find-and-replace across your HTML:
/* src/styles/global.css */
@import "tailwindcss";
@theme {
--color-brand: oklch(55% 0.22 265);
--color-brand-dark: oklch(48% 0.22 265);
--radius-modal: 1rem;
}
Tailwind v4 automatically generates utility classes from these variables. You can then use bg-brand, hover:bg-brand-dark, and rounded-modal throughout your modal markup without touching the HTML when the brand color changes.
Common issues and quick fixes
The modal flashes briefly on load
Make sure the [x-cloak] { display: none !important; } rule is in a <style> tag in the <head>, and that x-cloak is on every element controlled by x-show. If Tailwind's CDN is injecting styles after your <style> block, move the cloak rule to a <link>-loaded stylesheet instead.
The Escape key does nothing
The @keydown.escape.window listener needs to be on an element that is inside the x-data scope. If it is on the body or outside the wrapper, Alpine cannot access the close() method.
Steps do not animate between each other
By default, x-show toggles the CSS display property instantly. To crossfade steps, add x-transition to each step <div>. Keep the transitions short (100–150 ms) so the modal does not feel sluggish as the user navigates.
An onboarding modal is a small interaction, but the details — transitions, focus management, Escape handling, progress feedback — are what separate something that feels polished from something that feels thrown together. Alpine.js makes every one of these details a one-liner, and Tailwind keeps the styling collocated with the structure. The result is a component you can understand, copy, and adapt in a single sitting, with no build step required to get started.