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
divwith thestepclass; the first one starts withstyle="display: block;"and the rest withdisplay: none;. id="step1": personal information, withid="nextStep1"to move forward.id="step2": account information, withid="prevStep2"andid="nextStep2"to move both ways.id="step3": the confirmation, withid="prevStep3"andid="submitForm"to submit.id="step4": the submitted message.
Classes are removed for brevity, but I’ll keep those classes relevant to the tutorial.
<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");andlet currentStep = 0;: grab all the steps and keep track of which one is visible.function showStep(stepIndex) {: loops over the steps, setsdisplay: blockon the matching one anddisplay: noneon the rest.- The “Next” buttons save their step’s inputs into
formDatabefore moving forward;nextStep2also fills the confirmation spans withformData. - The “Previous” buttons decrement
currentStep, andsubmitFormmoves on to the final Submitted step.
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"(and2,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", andx-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.
<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