Tutorials 5 min read

How to create a multistep form with Tailwind CSS: JavaScript and Alpine.js

Build a multistep form wizard with Tailwind CSS, two ways: vanilla JavaScript or Alpine.js. Step navigation, saved form data, and a confirmation step.

Today we are building a multistep form: a small wizard that walks the user through a few steps and confirms the data at the end. We’ll build it twice, first with vanilla JavaScript (with an extra Submitted step), then with Alpine.js.

What is a multistep form?

A multistep form splits a longer form into several steps, each with its own set of fields and validation rules. It’s the classic pattern for wizards and registration flows where asking for everything on one screen would feel overwhelming.

Use cases

  • Wizards: guide the user through a series of steps in a fixed order.
  • Registration flows: split personal details and account credentials into separate screens.
  • Payment checkouts: collect shipping, billing, and card details one chunk at a time.
  • Document uploads: let users attach a resume or CV as its own dedicated step.

The markup

  • id="multiStepForm": wraps all the steps.
  • Each step is a div with the step class; the first one starts with style="display: block;" and the rest with display: none;.
  • id="step1": personal information, with id="nextStep1" to move forward.
  • id="step2": account information, with id="prevStep2" and id="nextStep2" to move both ways.
  • id="step3": the confirmation, with id="prevStep3" and id="submitForm" to submit.
  • id="step4": the submitted message.

Classes are removed for brevity, but I’ll keep those classes relevant to the tutorial.

html
<div>
  <div id="multiStepForm">
    <!-- Step 1 -->
    <div id="step1" class="step" style="display: block;">
      <h2>Step 1: Personal Information</h2>
      <div>
        <label for="name">Name</label>
        <input type="text" id="name" placeholder="Enter your name" />
      </div>
      <div>
        <label for="email">Email</label>
        <input type="email" id="email" placeholder="Enter your email" />
      </div>
      <div>
        <button id="nextStep1">Next</button>
      </div>
    </div>
    <!-- Step 2 -->
    <div id="step2" class="step" style="display: none;">
      <h2>Step 2: Account Information</h2>
      <div>
        <label for="username">Username</label>
        <input type="text" id="username" placeholder="Choose a username" />
      </div>
      <div>
        <label for="password">Password</label>
        <input
          type="password"
          id="password"
          placeholder="Enter your password"
        />
      </div>
      <div>
        <button id="prevStep2">Previous</button>
        <button id="nextStep2">Next</button>
      </div>
    </div>
    <!-- Step 3 -->
    <div id="step3" class="step" style="display: none;">
      <h2>Step 3: Confirmation</h2>
      <div>
        <p>Name: <span id="confirmName"></span></p>
        <p>Email: <span id="confirmEmail"></span></p>
        <p>Username: <span id="confirmUsername"></span></p>
      </div>
      <div>
        <button id="prevStep3">Previous</button>
        <button id="submitForm">Submit</button>
      </div>
    </div>
    <!-- Step 4 -->
    <div id="step4" class="step" style="display: none;">
      <h2>Step 4: Submitted</h2>
      <div>
        <p>Your form has been submitted successfully!</p>
      </div>
    </div>
  </div>
</div>

The script

  • document.addEventListener("DOMContentLoaded", function () {: waits for the DOM to be fully loaded before wiring anything up.
  • const formData = { name: "", email: "", username: "", password: "" };: stores the values collected along the way.
  • const steps = document.querySelectorAll(".step"); and let currentStep = 0;: grab all the steps and keep track of which one is visible.
  • function showStep(stepIndex) {: loops over the steps, sets display: block on the matching one and display: none on the rest.
  • The “Next” buttons save their step’s inputs into formData before moving forward; nextStep2 also fills the confirmation spans with formData.
  • The “Previous” buttons decrement currentStep, and submitForm moves on to the final Submitted step.
js
document.addEventListener("DOMContentLoaded", function () {
  const formData = {
    name: "",
    email: "",
    username: "",
    password: "",
  };
  const steps = document.querySelectorAll(".step");
  let currentStep = 0;

  function showStep(stepIndex) {
    steps.forEach((step, index) => {
      if (index === stepIndex) {
        step.style.display = "block";
      } else {
        step.style.display = "none";
      }
    });
  }
  showStep(currentStep);
  document.getElementById("nextStep1").addEventListener("click", function () {
    formData.name = document.getElementById("name").value;
    formData.email = document.getElementById("email").value;
    currentStep++;
    showStep(currentStep);
  });
  document.getElementById("prevStep2").addEventListener("click", function () {
    currentStep--;
    showStep(currentStep);
  });
  document.getElementById("nextStep2").addEventListener("click", function () {
    formData.username = document.getElementById("username").value;
    formData.password = document.getElementById("password").value;
    document.getElementById("confirmName").textContent = formData.name;
    document.getElementById("confirmEmail").textContent = formData.email;
    document.getElementById("confirmUsername").textContent = formData.username;
    currentStep++;
    showStep(currentStep);
  });
  document.getElementById("prevStep3").addEventListener("click", function () {
    currentStep--;
    showStep(currentStep);
  });
  document.getElementById("submitForm").addEventListener("click", function () {
    currentStep++;
    showStep(currentStep);
  });
});

The Alpine.js version

Same component, no separate script: the state lives in x-data. This version has three steps and ends at the confirmation.

  • x-data="{ step: 1, formData: { name: '', email: '', username: '', password: '' } }": holds the form data and the current step of the form.
  • x-show="step === 1" (and 2, 3): shows each step’s content only while it’s the current one.
  • x-model="formData.name", x-model="formData.email", x-model="formData.username", and x-model="formData.password": bind the inputs to the data object.
  • <button @click="step++">Next</button> and <button @click="step--">Previous</button>: move between steps.
  • x-text="formData.name" and friends: display the collected values on the confirmation step.

Classes are removed for brevity, but I’ll keep those classes relevant to the tutorial.

html
<div>
  <div
    x-data="{ step: 1, formData: { name: '', email: '', username: '', password: '' } }"
  >
    <!-- Step 1 -->
    <div x-show="step === 1">
      <h2>Step 1: Personal Information</h2>
      <div>
        <label for="name">Name</label>
        <input
          type="text"
          id="name"
          x-model="formData.name"
          placeholder="Enter your name"
        />
      </div>
      <div>
        <label for="email">Email</label>
        <input
          type="email"
          id="email"
          x-model="formData.email"
          placeholder="Enter your email"
        />
      </div>
      <div class="mt-4">
        <button @click="step++">Next</button>
      </div>
    </div>

    <!-- Step 2 -->
    <div x-show="step === 2">
      <h2>Step 2: Account Information</h2>
      <div>
        <label for="username">Username</label>
        <input
          type="text"
          id="username"
          x-model="formData.username"
          placeholder="Choose a username"
        />
      </div>
      <div>
        <label for="password">Password</label>
        <input
          type="password"
          id="password"
          x-model="formData.password"
          placeholder="Enter your password"
        />
      </div>
      <div>
        <button @click="step--">Previous</button>
        <button @click="step++">Next</button>
      </div>
    </div>

    <!-- Step 3 -->
    <div x-show="step === 3">
      <h2>Step 3: Confirmation</h2>
      <div>
        <p>Name: <span x-text="formData.name"></span></p>
        <p>Email: <span x-text="formData.email"></span></p>
        <p>Username: <span x-text="formData.username"></span></p>
      </div>
      <!-- Add more fields as needed -->
      <div>
        <button @click="step--">Previous</button>
        <button>Submit</button>
      </div>
    </div>
  </div>
</div>

You can try the Alpine version separately: live demo and source code.

Which one should you use?

Alpine keeps the step logic right in the markup, which is hard to beat if it’s already on the page. The vanilla version needs a bit more wiring but works anywhere, and it made adding the extra Submitted step easy.

Conclusion

We built the same wizard twice: steps, navigation, collected data, and a confirmation. Do not forget to make it fully responsive and accessible before you ship it.

Hope you enjoyed this tutorial and have a great day!

/Michael Andreuzza