Step Validation and Progress Indicator
Multi-step forms lift conversion when they are executed cleanly from a UX perspective: a clear progress indicator, validation on every step instead of only at submit time, and reliable backward navigation without losing any data. With Alpine.js this pattern can be fully implemented without any external library.
Table of Contents
- 1. Why multi-step instead of one long form?
- 2. Data structure: steps, fields and error objects
- 3. Navigation: forward, backward and progress
- 4. Validation: step by step instead of submit-time validation
- 5. Error display: inline errors per field
- 6. Progress indicator: visual feedback with Tailwind
- 7. Submit handling and loading state
- 8. Accessibility: ARIA attributes and keyboard navigation
- 9. Comparison: Alpine.js wizard vs. VeeValidate vs. Formik
- 10. Summary
- 11. FAQ
1. Why multi-step instead of one long form?
Long, single-page forms have a higher abandonment rate than multi-step forms that split the same content into logical steps. That is not just about aesthetics: cognitive load drops when users see only one group of related fields at a time. A checkout form with personal data, shipping address, payment method and order confirmation can be split into four clearly separable blocks, and that is exactly the natural basis for a wizard.
In Magento 2 with Hyva, the checkout form is already a multi-step process, but custom code modules or the theme frequently bring requirements for custom wizards: product configurators, multi-step quote request forms, customer data capture forms after registration, or onboarding flows for B2B portals. Alpine.js is ideal for these use cases: no build step, no framework overhead, usable directly inside phtml templates. The entire wizard logic fits into a single x-data function.
2. Data structure: steps, fields and error objects
The data structure of an Alpine.js wizard consists of a handful of reactive properties. currentStep is an integer (zero-based) that stores the currently displayed step. totalSteps is the total number of steps, either read from the template or defined as a constant. formData is a flat object holding every form field from every step. The fields of all steps live in a single object so that data survives the transition between steps. errors is a parallel object with the same keys, holding either the error message or null for each field.
Which fields belong to which step is not encoded in the data structure at all, but in the template via x-show="currentStep === 0" on the step containers. This separation keeps the JavaScript logic simple and the step configuration in the HTML, where it is visually traceable. An array of step definitions, with names, fields and validation rules, would make the component more configurable but also more complex. For most use cases the simpler variant is preferable.
// Multi-step wizard data structure with validation
function formWizard() {
return {
currentStep: 0,
totalSteps: 3,
isSubmitting: false,
isSuccess: false,
formData: {
// Step 1: Personal data
firstName: '',
lastName: '',
email: '',
// Step 2: Shipping address
street: '',
city: '',
zip: '',
country: 'DE',
// Step 3: Options
newsletter: false,
message: ''
},
errors: {
firstName: null,
lastName: null,
email: null,
street: null,
city: null,
zip: null,
country: null,
message: null
},
// Validation rules per step: returns array of field names for that step
stepFields: {
0: ['firstName', 'lastName', 'email'],
1: ['street', 'city', 'zip'],
2: [] // Optional step, no required fields
}
};
}
3. Navigation: forward, backward and progress
Forward navigation in the wizard always goes through a nextStep() method, which validates the current step before the transition happens. Only when there are no errors does currentStep get incremented. This coupling is the decisive UX benefit of the wizard pattern: users can only move forward once the current step is valid. Jumping directly to a later step, for example by clicking the progress bar, only makes sense if that jump targets a step that has already been validated.
Backward navigation in prevStep() is much simpler: it decrements currentStep without any validation at all. That is deliberate, because users must always be able to go back without losing data. A common mistake is clearing the current step's form data on the way back. That is wrong from a UX perspective: users expect their entered data to be preserved. Because formData is kept as a single shared object across all steps, this happens correctly by default with this pattern.
4. Validation: step by step instead of submit-time validation
The validation logic is centralized in a method called validateStep(stepIndex). This method reads the field list for the given step from stepFields, iterates over every field name and runs the matching validation rule. Errors get written to this.errors[fieldName], and on success it is reset to null. The method returns true when no errors were found. nextStep() uses this return value to decide whether the step transition may proceed.
Validation rules can be defined as simple functions: required: v => v.trim() !== '', email: v => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v), zip: v => /^\d{5}$/.test(v). Error messages should be specific and tell the user clearly what to do, not just what is wrong. "Please enter a valid email address" is better than "Invalid email". Real-time validation on @input or @blur further improves the UX, since errors disappear as soon as the field is filled in correctly.
// Step validation and navigation methods
{
validateStep(step) {
const fields = this.stepFields[step] || [];
let valid = true;
// Reset errors for this step's fields
fields.forEach(f => this.errors[f] = null);
for (const field of fields) {
const value = this.formData[field];
if (field === 'email') {
if (!value.trim()) {
this.errors.email = 'Bitte geben Sie Ihre E-Mail-Adresse ein.';
valid = false;
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
this.errors.email = 'Bitte geben Sie eine gültige E-Mail-Adresse ein.';
valid = false;
}
} else if (field === 'zip') {
if (!/^\d{4,5}$/.test(value)) {
this.errors.zip = 'Bitte geben Sie eine gültige Postleitzahl ein.';
valid = false;
}
} else {
if (!value?.toString().trim()) {
this.errors[field] = 'Dieses Feld ist erforderlich.';
valid = false;
}
}
}
return valid;
},
nextStep() {
if (this.validateStep(this.currentStep)) {
this.currentStep = Math.min(this.currentStep + 1, this.totalSteps - 1);
this.$nextTick(() => this.$el.scrollIntoView({ behavior: 'smooth' }));
}
},
prevStep() {
this.currentStep = Math.max(this.currentStep - 1, 0);
this.$nextTick(() => this.$el.scrollIntoView({ behavior: 'smooth' }));
}
}
5. Error display: inline errors per field
Inline errors directly below the affected field are the most user-friendly form of error display. With Alpine.js this can be controlled declaratively in the template via x-show and x-text: <p x-show="errors.email" x-text="errors.email" class="text-red-500 text-sm mt-1"></p>. The element is only visible when an error exists, and it automatically displays the current error message. The input field itself dynamically receives an error border style: :class="{'border-red-500': errors.email, 'border-slate-300': !errors.email}".
An important UX detail: errors should only appear after the first attempt to move forward, not already when the form loads. That is guaranteed automatically by the state of the errors object: every field starts as null, and errors are only set by validateStep(), which is only called from nextStep(). Real-time error resolution, meaning an error disappearing as soon as the field is filled in correctly, can be implemented via @input="if (errors.email) validateField('email')".
6. Progress indicator: visual feedback with Tailwind
A clear progress indicator is one of the most important UX elements of a wizard. It shows the user where they are in the process and how many steps remain. The simplest form is a progress bar: <div class="h-2 bg-teal-600 transition-all duration-300" :style="`width: ${(currentStep / (totalSteps - 1)) * 100}%`"></div>. This bar fills up with every step and gives immediate visual feedback.
For multi-step progress indicators with step labels, a row of dots connected by lines works well. Each dot represents one step: green for completed steps, marked active for the current one, gray for future ones. In Alpine.js this can be implemented with x-for over an array of step objects combined with :class bindings. The x-for pattern is ideal here because the number of steps is defined in the template and the classes depend declaratively on currentStep.
7. Submit handling and loading state
The submit step in the wizard is the final step and triggers the actual data transfer. The submit() method first sets isSubmitting = true to disable the submit button and show a loading indicator. It then runs a final validation across all steps (not only the last one) to make sure no fields are empty. After that the API call is made with fetch. On success, isSuccess = true is set and a success message is displayed. On failure, a general error message is stored in submitError.
The isSubmitting state is important: it prevents double submits from fast double clicks and gives the user visual feedback that something is happening. The button receives :disabled="isSubmitting" and x-text="isSubmitting ? 'Sending...' : 'Submit'". In Hyva projects, the submit endpoint can be a Magento REST endpoint or a custom controller invoked through the Magento API. The form pattern itself stays independent of the backend endpoint.
// Submit method with loading state and error handling
{
submitError: null,
async submit() {
// Final validation of all steps
let allValid = true;
for (let i = 0; i < this.totalSteps; i++) {
if (!this.validateStep(i)) allValid = false;
}
if (!allValid) {
this.submitError = 'Bitte prüfen Sie die markierten Felder.';
return;
}
this.isSubmitting = true;
this.submitError = null;
try {
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.formData)
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.message || 'Fehler beim Senden');
}
this.isSuccess = true;
this.$dispatch('wizard-complete', { formData: this.formData });
} catch (e) {
this.submitError = e.message || 'Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.';
} finally {
this.isSubmitting = false;
}
}
}
8. Accessibility: ARIA attributes and keyboard navigation
An accessible form wizard needs several ARIA adjustments. The progress area gets role="group" with aria-label="Form progress". Active step indicators receive aria-current="step". Every step container gets role="region" with a unique aria-labelledby attribute pointing at the step heading. These attributes help screen reader users understand the structure of the wizard and identify the current step.
Keyboard navigation in the wizard should make sure focus moves to the first field of the new step whenever the step changes. This prevents focus from staying on the "Next" button while a new step is displayed. With Alpine.js: this.$nextTick(() => { const firstInput = this.$el.querySelector(`[data-step="${this.currentStep}"] input`); if (firstInput) firstInput.focus(); }). Error messages are marked up with role="alert" so screen readers announce them immediately when they appear, without users having to navigate actively.
9. Comparison: Alpine.js wizard vs. VeeValidate vs. Formik
| Criterion | Alpine.js (native) | VeeValidate (Vue) | Formik (React) |
|---|---|---|---|
| Bundle size | 0 KB extra | ~32 KB gzip | ~12 KB gzip |
| Hyva compatible | Fully | No (requires Vue) | No (requires React) |
| Schema validation | Implement yourself | Yup/Zod built in | Yup/Zod built in |
| Learning curve | Low | Medium | Medium to high |
| Debugging | Direct access | Vue DevTools | React DevTools |
The table makes it clear: for Hyva projects there is no real alternative to the native Alpine.js approach. VeeValidate and Formik require Vue or React as a framework, neither of which exists in Hyva. The downside of the native Alpine.js approach, implementing validation rules yourself, is in practice less severe than it sounds: most forms only need a handful of rules (required, email, zip, minLength), and these can be implemented in fewer than twenty lines.
Mironsoft
Alpine.js forms, Hyva Themes and Magento 2 custom modules
Need a custom form wizard for Magento 2 with Hyva?
We build multi-step forms for inquiry flows, product configurators and onboarding processes, fully in Alpine.js, CSP compliant, accessible and cleanly integrated with your backend.
Wizard development
Multi-step forms with validation, progress indicator and submit handling
Backend integration
REST endpoints, Magento controllers or external APIs as the form target
Accessibility
WCAG compliant ARIA attributes, keyboard navigation and screen reader tests
10. Summary
A multi-step form wizard with Alpine.js is a clear, maintainable pattern without external dependencies. The data structure is simple: one flat formData object for all steps, a parallel errors object, and an integer for the current step. Validation runs per step in validateStep(), navigation in nextStep() and prevStep(). The submit handler uses isSubmitting as a guard against double submits and runs a final overall validation.
The pattern scales well: with three to seven steps the component stays manageable. For very many steps or complex conditional logic (step C only when step B has certain values), an array-based step configuration is recommended. Accessibility is not an afterthought: ARIA attributes, focus management on step transitions, and role="alert" on error messages should be part of the implementation from the start.
Alpine.js Form Wizard: The Key Takeaways
Data structure
formData flat for all steps, parallel errors object. Step assignment lives in the template via x-show, not in the JS object.
Validation
validateStep(step) returns true/false. nextStep() blocks on error. Errors go into errors[field], null on success.
Submit protection
isSubmitting flag prevents double submits. Final overall validation before the API call. try/catch/finally for the loading state.
Accessibility
aria-current="step" on the active step, role="alert" on error messages, focus jump to the first field on step transitions.