A multi-step onboarding modal helps guide new users through your app’s key features without navigating away from the current page. Using Alpine.js for lightweight state management and Tailwind CSS for styling, you can create a helpful walkthrough with just HTML and a bit of JavaScript.

In this post, I’ll walk you through building a fully functional onboarding modal that includes:

  • State management for slides, navigation, and keyboard controls
  • Responsive Tailwind markup for buttons, overlays, and dialogs
  • Dynamic content binding for titles, descriptions, and media
  • Interactive elements like theme selection and dot navigation
  • Custom events for completion handling

The modal will work on keyboard and touch, and is easy to customize.

1. Set up the Alpine.js state

Let’s start by creating the main component wrapper with Alpine’s x-data directive. This object will manage the modal’s state, including the current slide index, slide data, and navigation methods.

<div
  class="w-full max-w-xl"
  x-data="{
    open: true,
    i: 0,
    slides: [
      { id: 'welcome', title: 'Welcome – Quick Tour', body: 'Here are a few tips to get you productive in under a minute.', media: 'welcome' },
      { id: 'theme', title: 'Pick a Theme', body: 'Choose the interface that feels right. You can change it anytime.', media: 'theme' },
      { id: 'shortcuts', title: 'Keyboard Shortcuts', body: 'Open the command bar with ⌘K or /, then use ↑ ↓ and ↵ to navigate.', media: 'kbd' },
      { id: 'done', title: 'You’re All Set', body: 'We’ve configured the basics. You can revisit this tour from Settings.', media: 'done' }
    ],
    theme: 'system',
    get total() { return this.slides.length; },
    get last() { return this.i === this.total - 1; },
    show() { this.open = true; this.i = 0; },
    close() { this.open = false; },
    next() { if (this.i < this.total - 1) this.i++; else this.finish(); },
    prev() { if (this.i > 0) this.i--; },
    jump(n) { if (n >= 0 && n < this.total) this.i = n; },
    finish() { this.open = false; window.dispatchEvent(new CustomEvent('onboarding:complete', { detail: { theme: this.theme } })); },
    onKey(e) { if (!this.open) return; if (e.key === 'ArrowRight') { e.preventDefault(); this.next(); } if (e.key === 'ArrowLeft') { e.preventDefault(); this.prev(); } }
  }"
  @keydown.window="onKey($event)"
>

Here’s what each part does:

  • slides is an array of objects containing each step’s ID, title, body text, and media key.
  • Computed properties like total and last help with navigation logic.
  • Methods like next(), prev(), and jump() handle slide transitions.
  • finish() closes the modal and dispatches a custom event with the selected theme.
  • onKey() enables keyboard navigation with arrow keys.

2. Add the trigger button and backdrop

Now, let’s add a button to trigger the modal and an overlay backdrop that appears when the modal is open.

  <button
    class="relative flex items-center justify-center text-center font-medium transition-colors duration-200 ease-in-out select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:z-10 rounded-md text-white bg-zinc-900 outline outline-zinc-900 hover:bg-zinc-950 focus-visible:outline-zinc-950 h-9 px-4 text-sm mx-auto"
    type="button"
    @click="show()"
  >
    Start Onboarding
  </button>
  <div
    x-show="open"
    x-transition.opacity
    class="fixed inset-0 z-40 bg-black/40"
    aria-hidden="true"
    @click="close()"
  ></div>

Key points:

  • The button uses show() to open the modal and reset to the first slide.
  • The backdrop uses Alpine’s transition for smooth fade-in/out.
  • Clicking the backdrop calls close(), which hides the modal and disables keyboard events.

3. Create the dialog structure

Next, let’s build the modal dialog with transitions, media placeholders, and accessibility attributes.

  <div
    x-show="open"
    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 z-50 flex items-end sm:items-center justify-center p-4"
    role="dialog"
    aria-modal="true"
    aria-labelledby="ob-title"
    @keydown.escape.window.prevent="close()"
  >
    <div
      x-show="open"
      @click.outside="close()"
      x-transition:enter="transition ease-out duration-200"
      x-transition:enter-start="translate-y-4 sm:translate-y-0 sm:scale-95"
      x-transition:enter-end="translate-y-0 sm:scale-100"
      x-transition:leave="transition ease-in duration-150"
      x-transition:leave-start="opacity-100"
      x-transition:leave-end="opacity-0"
      class="w-full sm:max-w-md rounded-2xl bg-white shadow-xl outline outline-1 outline-black/5"
    >
      <div class="p-4 pb-0">
        <div class="rounded-xl overflow-hidden bg-radial-[at_50%_75%] from-sky-200 via-blue-400 to-indigo-900 to-90%">
          <div class="aspect-[16/9] grid place-items-center">
            <template x-if="slides[i].media === 'welcome'">
              <!-- Welcome SVG or animation -->
            </template>
            <template x-if="slides[i].media === 'theme'">
              <!-- Theme preview thumbnails -->
            </template>
            <template x-if="slides[i].media === 'kbd'">
              <!-- Keyboard shortcut illustration -->
            </template>
            <template x-if="slides[i].media === 'done'">
              <!-- Success icon -->
            </template>
          </div>
        </div>
      </div>

What this does:

  • The outer div handles the modal’s positioning and transitions.
  • The inner div is the card that scales and slides in.
  • Media placeholders use Alpine’s x-if to conditionally render content based on the current slide.
  • Accessibility features include role="dialog", aria-modal, and escape key handling.

4. Implement content, dots, and controls

Let’s add the slide content, dot navigation, and footer buttons.

      <div class="px-5 py-4">
        <h3 class="text-base font-semibold text-zinc-900" id="ob-title" x-text="slides[i].title"></h3>
        <p class="text-sm text-zinc-600 mt-1" x-text="slides[i].body"></p>
        <div class="mt-3" x-show="slides[i].id === 'theme'">
          <div class="inline-flex gap-2">
            <button
              @click="theme = 'light'"
              :class="theme === 'light' ? 'bg-zinc-900 text-white' : 'outline outline-1 outline-zinc-200 text-zinc-700'"
              class="h-8 px-2 rounded-md text-xs inline-flex items-center gap-1 font-medium"
            >
              Light
            </button>
            <button
              @click="theme = 'system'"
              :class="theme === 'system' ? 'bg-zinc-900 text-white' : 'outline outline-1 outline-zinc-200 text-zinc-700'"
              class="h-8 px-2 rounded-md text-xs inline-flex items-center gap-1 font-medium"
            >
              System
            </button>
            <button
              @click="theme = 'dark'"
              :class="theme === 'dark' ? 'bg-zinc-900 text-white' : 'outline outline-1 outline-zinc-200 text-zinc-700'"
              class="h-8 px-2 rounded-md text-xs inline-flex items-center gap-1 font-medium"
            >
              Dark
            </button>
          </div>
        </div>
        <div class="mt-4 flex items-center gap-2">
          <template x-for="(s, idx) in slides" :key="s.id">
            <button
              @click="jump(idx)"
              :aria-current="i === idx"
              :class="i === idx ? 'bg-blue-600' : 'bg-zinc-300'"
              class="size-2 rounded-full"
            ></button>
          </template>
        </div>
      </div>
      <div class="px-5 py-4 border-t border-zinc-100 flex items-center gap-2">
        <button
          class="relative flex items-center justify-center text-center font-medium transition-colors duration-200 ease-in-out select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:z-10 rounded-md text-zinc-700 bg-white outline outline-zinc-200 hover:shadow-sm hover:bg-zinc-50 focus-visible:outline-zinc-900 h-7 px-3 text-xs"
          type="button"
          @click="close()"
        >
          Skip
        </button>
        <div class="ml-auto flex items-center gap-2">
          <button
            class="relative flex items-center justify-center text-center font-medium transition-colors duration-200 ease-in-out select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:z-10 rounded-md text-zinc-700 bg-white outline outline-zinc-200 hover:shadow-sm hover:bg-zinc-50 focus-visible:outline-zinc-900 h-7 px-3 text-xs"
            type="button"
            @click="prev()"
            :disabled="i === 0"
          >
            Back
          </button>
          <button
            class="relative flex items-center justify-center text-center font-medium transition-colors duration-200 ease-in-out select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:z-10 rounded-md text-white bg-blue-700 outline outline-blue-700 hover:bg-blue-600 focus-visible:outline-blue-600 h-7 px-3 text-xs"
            type="button"
            @click="next()"
            x-text="last ? 'Finish' : 'Continue'"
          ></button>
        </div>
      </div>
    </div>
  </div>
</div>

Breaking it down:

  • Slide titles and bodies update dynamically using x-text.
  • Theme selection buttons toggle the theme state and update their appearance.
  • Dot navigation uses x-for to render buttons for each slide.
  • Footer buttons include Skip, Back, and Continue/Finish, with the latter’s text changing based on position.

5. Handle completion events

Finally, the modal emits a custom event when the tour finishes. Listen for it to perform actions like saving preferences.

window.addEventListener('onboarding:complete', (event) => {
  console.log('Selected theme:', event.detail.theme);
  // Save to localStorage, update analytics, or modify app state
});

What to do with it:

  • The event includes the chosen theme in event.detail.theme.
  • Use this to persist user choices or trigger follow-up actions.
  • No cleanup is needed since finish() handles closing the modal.

This component is ready to add to any project using Alpine.js. Customize the slides, add more interactive elements, or integrate with your app’s theme system for a smooth onboarding experience.

/Michael Andreuzza

Did you like this post? Please share it with your friends!