Multi-step Forms
Create wizard-style forms with state management, progressive validation, and navigation for complex data entry workflows using the NuxtUI Stepper component.
Why Multi-step Forms
- ✅ Break complex forms into manageable steps
- ✅ Improve user experience with progressive disclosure
- ✅ Reduce form abandonment rates
- ✅ Validate progressively to catch errors early
- ✅ Provide clear visual progress indicators
Using NuxtUI Stepper
The UStepper component from NuxtUI provides a beautiful, accessible stepper UI out of the box.
Basic Setup
<script setup lang="ts">
import type { StepperItem } from '@nuxt/ui'
const steps = ref<StepperItem[]>([
{
title: 'Account',
description: 'Create your account',
icon: 'i-lucide-user'
},
{
title: 'Profile',
description: 'Complete your profile',
icon: 'i-lucide-contact'
},
{
title: 'Review',
description: 'Review and confirm',
icon: 'i-lucide-check-circle'
}
])
const currentStep = ref(0)
</script>
<template>
<UStepper v-model="currentStep" :items="steps" />
</template>
Complete Registration Example
Form State Management
<script setup lang="ts">
import type { StepperItem } from '@nuxt/ui'
const steps = ref<StepperItem[]>([
{
title: 'Account',
description: 'Create your account',
icon: 'i-lucide-user'
},
{
title: 'Profile',
description: 'Complete your profile',
icon: 'i-lucide-contact'
},
{
title: 'Preferences',
description: 'Set your preferences',
icon: 'i-lucide-settings'
}
])
const currentStep = ref(0)
// Centralized form data
const formData = ref({
// Step 1
email: '',
password: '',
// Step 2
firstName: '',
lastName: '',
company: '',
// Step 3
notifications: true,
theme: 'light'
})
const errors = ref<Record<string, string>>({})
function validateStep(step: number): boolean {
errors.value = {}
if (step === 0) {
if (!formData.value.email) {
errors.value.email = 'Email is required'
return false
}
if (!formData.value.password || formData.value.password.length < 8) {
errors.value.password = 'Password must be at least 8 characters'
return false
}
} else if (step === 1) {
if (!formData.value.firstName) {
errors.value.firstName = 'First name is required'
return false
}
if (!formData.value.lastName) {
errors.value.lastName = 'Last name is required'
return false
}
}
return true
}
function nextStep() {
if (validateStep(currentStep.value)) {
currentStep.value++
}
}
function previousStep() {
currentStep.value--
}
async function submit() {
if (!validateStep(currentStep.value)) return
await $fetch('/api/register', {
method: 'POST',
body: formData.value
})
}
</script>
<template>
<div class="max-w-3xl mx-auto">
<!-- Stepper Component -->
<UStepper
v-model="currentStep"
:items="steps"
/>
<!-- Step Content -->
<div class="mt-8">
<!-- Step 1: Account -->
<div v-if="currentStep === 0">
<UFormGroup label="Email" :error="errors.email">
<UInput v-model="formData.email" type="email" />
</UFormGroup>
<UFormGroup label="Password" :error="errors.password" class="mt-4">
<UInput v-model="formData.password" type="password" />
</UFormGroup>
</div>
<!-- Step 2: Profile -->
<div v-else-if="currentStep === 1">
<UFormGroup label="First Name" :error="errors.firstName">
<UInput v-model="formData.firstName" />
</UFormGroup>
<UFormGroup label="Last Name" :error="errors.lastName" class="mt-4">
<UInput v-model="formData.lastName" />
</UFormGroup>
<UFormGroup label="Company (optional)" class="mt-4">
<UInput v-model="formData.company" />
</UFormGroup>
</div>
<!-- Step 3: Preferences -->
<div v-else>
<UFormGroup label="Notifications">
<UToggle v-model="formData.notifications" />
</UFormGroup>
<UFormGroup label="Theme" class="mt-4">
<URadioGroup
v-model="formData.theme"
:options="[
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' }
]"
/>
</UFormGroup>
</div>
</div>
<!-- Navigation -->
<div class="flex justify-between mt-8">
<UButton
v-if="currentStep > 0"
@click="previousStep"
variant="outline"
>
Previous
</UButton>
<div v-else />
<UButton
v-if="currentStep < steps.length - 1"
@click="nextStep"
>
Next
</UButton>
<UButton
v-else
@click="submit"
color="green"
>
Submit
</UButton>
</div>
</div>
</template>
Progressive Validation with Zod
Combine NuxtUI Stepper with Zod for robust validation:
import { z } from 'zod'
// Define schemas per step
const accountSchema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Password must be at least 8 characters')
})
const profileSchema = z.object({
firstName: z.string().min(2, 'First name required'),
lastName: z.string().min(2, 'Last name required')
})
function validateStep(step: number): boolean {
try {
if (step === 0) {
accountSchema.parse({
email: formData.value.email,
password: formData.value.password
})
} else if (step === 1) {
profileSchema.parse({
firstName: formData.value.firstName,
lastName: formData.value.lastName
})
}
errors.value = {}
return true
} catch (error) {
if (error instanceof z.ZodError) {
const fieldErrors = error.flatten().fieldErrors
errors.value = Object.fromEntries(
Object.entries(fieldErrors).map(([key, val]) => [key, val?.[0] || ''])
)
}
return false
}
}
Draft Saving
Save form progress to resume later:
// app/composables/useDraftSaving.ts
export function useDraftSaving<T>(key: string, initialData: T) {
const formData = ref<T>(initialData)
const currentStep = ref(0)
// Load draft on mount
onMounted(() => {
if (import.meta.client) {
const saved = localStorage.getItem(key)
if (saved) {
const draft = JSON.parse(saved)
formData.value = draft.data
currentStep.value = draft.step
}
}
})
// Save draft on change
watch([formData, currentStep], () => {
if (import.meta.client) {
localStorage.setItem(key, JSON.stringify({
data: formData.value,
step: currentStep.value,
savedAt: new Date().toISOString()
}))
}
}, { deep: true })
function clearDraft() {
if (import.meta.client) {
localStorage.removeItem(key)
}
}
return {
formData,
currentStep,
clearDraft
}
}
Usage:
<script setup lang="ts">
const { formData, currentStep, clearDraft } = useDraftSaving('registration-form', {
email: '',
password: '',
firstName: '',
lastName: ''
})
async function submit() {
await $fetch('/api/register', {
method: 'POST',
body: formData.value
})
clearDraft() // Remove draft after successful submission
}
</script>
Navigation Guards
Prevent accidental navigation away with unsaved changes:
// app/composables/useFormGuard.ts
export function useFormGuard(isDirty: Ref<boolean>) {
const router = useRouter()
onBeforeRouteLeave((to, from) => {
if (isDirty.value) {
const answer = window.confirm(
'You have unsaved changes. Do you really want to leave?'
)
if (!answer) return false
}
})
onMounted(() => {
if (import.meta.client) {
window.addEventListener('beforeunload', handleBeforeUnload)
}
})
onUnmounted(() => {
if (import.meta.client) {
window.removeEventListener('beforeunload', handleBeforeUnload)
}
})
function handleBeforeUnload(e: BeforeUnloadEvent) {
if (isDirty.value) {
e.preventDefault()
e.returnValue = ''
}
}
}
Best Practices
- ✅ Use NuxtUI Stepper for consistent, accessible UI
- ✅ Validate each step before allowing progression
- ✅ Save drafts to improve user experience
- ✅ Provide clear error messages
- ✅ Allow navigation back to previous steps
- ✅ Show overall progress clearly
- ✅ Use route guards to prevent accidental data loss
- ✅ Consider mobile responsiveness
Advanced Patterns
Dynamic Steps
const steps = computed<StepperItem[]>(() => {
const baseSteps = [
{ title: 'Account', icon: 'i-lucide-user' },
{ title: 'Profile', icon: 'i-lucide-contact' }
]
// Add optional step based on user choice
if (formData.value.needsCompanyInfo) {
baseSteps.push({
title: 'Company',
icon: 'i-lucide-building'
})
}
baseSteps.push({
title: 'Review',
icon: 'i-lucide-check-circle'
})
return baseSteps
})
Step Validation Summary
<script setup lang="ts">
const stepValidation = ref({
0: false, // Account step
1: false, // Profile step
2: false // Preferences step
})
function updateStepValidation(step: number, isValid: boolean) {
stepValidation.value[step] = isValid
}
const steps = computed<StepperItem[]>(() => [
{
title: 'Account',
icon: stepValidation.value[0]
? 'i-lucide-check-circle'
: 'i-lucide-user'
},
{
title: 'Profile',
icon: stepValidation.value[1]
? 'i-lucide-check-circle'
: 'i-lucide-contact'
},
{
title: 'Preferences',
icon: stepValidation.value[2]
? 'i-lucide-check-circle'
: 'i-lucide-settings'
}
])
</script>
Multi-step forms with NuxtUI Stepper provide an excellent user experience for complex data entry. Combine with validation, draft saving, and navigation guards for a production-ready solution. v-model="formData.confirmPassword" type="password" placeholder="••••••••" />
<!-- Step 2: Profile -->
<div v-if="currentStep.id === 'profile'" class="step-content">
<h2>{{ currentStep.title }}</h2>
<div class="form-field">
<label>First Name</label>
<input
v-model="formData.firstName"
type="text"
placeholder="John"
/>
</div>
<div class="form-field">
<label>Last Name</label>
<input
v-model="formData.lastName"
type="text"
placeholder="Doe"
/>
</div>
<div class="form-field">
<label>Phone (Optional)</label>
<input
v-model="formData.phone"
type="tel"
placeholder="+1 234 567 8900"
/>
</div>
</div>
<!-- Step 3: Preferences -->
<div v-if="currentStep.id === 'preferences'" class="step-content">
<h2>{{ currentStep.title }}</h2>
<div class="form-field">
<label>
<input v-model="formData.newsletter" type="checkbox" />
Subscribe to newsletter
</label>
</div>
<div class="form-field">
<label>
<input v-model="formData.notifications" type="checkbox" />
Enable notifications
</label>
</div>
<div class="form-field">
<label>Theme</label>
<select v-model="formData.theme">
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="auto">Auto</option>
</select>
</div>
</div>
<!-- Step 4: Review -->
<div v-if="currentStep.id === 'review'" class="step-content">
<h2>{{ currentStep.title }}</h2>
<div class="review-section">
<h3>Account</h3>
<p><strong>Email:</strong> {{ formData.email }}</p>
</div>
<div class="review-section">
<h3>Profile</h3>
<p><strong>Name:</strong> {{ formData.firstName }} {{ formData.lastName }}</p>
<p v-if="formData.phone"><strong>Phone:</strong> {{ formData.phone }}</p>
</div>
<div class="review-section">
<h3>Preferences</h3>
<p><strong>Newsletter:</strong> {{ formData.newsletter ? 'Yes' : 'No' }}</p>
<p><strong>Notifications:</strong> {{ formData.notifications ? 'Yes' : 'No' }}</p>
<p><strong>Theme:</strong> {{ formData.theme }}</p>
</div>
</div>
</div>
<!-- Navigation Buttons -->
<div class="form-actions">
<button
v-if="!isFirstStep"
type="button"
@click="previousStep"
>
Previous
</button>
<button
v-if="!isLastStep"
type="button"
@click="handleNext"
>
Next
</button>
<button
v-if="isLastStep"
type="button"
@click="handleSubmit"
>
Submit
</button>
</div>
Draft Saving
Auto-save Composable
// app/composables/useFormDraft.ts
import { useDebounceFn, useLocalStorage } from '@vueuse/core'
export function useFormDraft<T>(
key: string,
formData: Ref<T>,
options: {
debounce?: number
storageType?: 'local' | 'session'
} = {}
) {
const { debounce = 1000, storageType = 'local' } = options
const storage = storageType === 'local' ? useLocalStorage : useSessionStorage
const draft = storage<T | null>(key, null)
// Save draft on data change
const saveDraft = useDebounceFn(() => {
draft.value = formData.value
}, debounce)
watch(formData, saveDraft, { deep: true })
function loadDraft(): T | null {
return draft.value
}
function clearDraft() {
draft.value = null
}
function hasDraft(): boolean {
return draft.value !== null
}
return {
loadDraft,
clearDraft,
hasDraft
}
}
Using Draft Saving
const { formData, updateFormData } = useRegistrationForm()
const { loadDraft, clearDraft, hasDraft } = useFormDraft(
'registration-draft',
formData,
{ debounce: 2000 }
)
// On mount, check for draft
onMounted(() => {
if (hasDraft()) {
const shouldRestore = confirm('Resume previous registration?')
if (shouldRestore) {
const draft = loadDraft()
if (draft) {
updateFormData(draft)
}
} else {
clearDraft()
}
}
})
// Clear draft on successful submission
async function handleSubmit() {
const success = await submitForm()
if (success) {
clearDraft()
navigateTo('/dashboard')
}
}
Navigation Guards
Prevent Accidental Navigation
<script setup lang="ts">
const { isDirty } = useRegistrationForm()
// Browser navigation warning
onBeforeRouteLeave((to, from, next) => {
if (isDirty.value) {
const answer = window.confirm(
'You have unsaved changes. Are you sure you want to leave?'
)
if (answer) {
next()
} else {
next(false)
}
} else {
next()
}
})
// Browser close/reload warning
onMounted(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (isDirty.value) {
e.preventDefault()
e.returnValue = ''
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
onUnmounted(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
})
})
</script>
Progressive Validation
Real-time Field Validation
<script setup lang="ts">
import { z } from 'zod'
const email = ref('')
const emailError = ref('')
const emailSchema = z.string().email()
// Validate on blur
const validateEmail = useDebounceFn(() => {
const result = emailSchema.safeParse(email.value)
emailError.value = result.success ? '' : 'Invalid email address'
}, 300)
watch(email, validateEmail)
</script>
<template>
<div class="form-field">
<label>Email</label>
<input
v-model="email"
type="email"
:class="{ error: emailError }"
/>
<span v-if="emailError" class="error">{{ emailError }}</span>
<span v-else-if="email && !emailError" class="success">✓ Valid</span>
</div>
</template>
Conditional Steps
export function useConditionalStepForm() {
const formData = ref({ accountType: 'personal' })
const steps = computed(() => {
const baseSteps = [
{ id: 'account-type', title: 'Account Type' },
{ id: 'basic-info', title: 'Basic Information' }
]
// Add business step only for business accounts
if (formData.value.accountType === 'business') {
baseSteps.push({
id: 'business-info',
title: 'Business Information'
})
}
baseSteps.push({ id: 'review', title: 'Review' })
return baseSteps
})
return useMultiStepForm(steps.value, formData.value)
}
Best Practices
✅ Do's
- ✅ Save drafts automatically for long forms
- ✅ Show clear progress indicators
- ✅ Validate each step before proceeding
- ✅ Allow users to go back and edit previous steps
- ✅ Provide visual feedback for completed steps
- ✅ Warn users before losing unsaved changes
- ✅ Keep step titles short and descriptive
❌ Don'ts
- ❌ Don't make forms longer than necessary
- ❌ Don't validate on every keystroke (use debounce)
- ❌ Don't hide the progress indicator
- ❌ Don't lose data when navigating between steps
- ❌ Don't submit without final review step
- ❌ Don't forget to clear sensitive data from storage
Advanced Patterns
Step-by-step Async Validation
async function checkEmailAvailability(email: string): Promise<boolean> {
const { available } = await $fetch('/api/check-email', {
params: { email }
})
return available
}
const steps = [
{
id: 'account',
title: 'Account',
validate: async (data) => {
const result = accountSchema.safeParse(data)
if (!result.success) return false
// Async validation
const available = await checkEmailAvailability(data.email)
if (!available) {
throw new Error('Email already registered')
}
return true
}
}
]
Branching Logic
function getNextStep(currentStepId: string, formData: any): string {
if (currentStepId === 'account-type') {
return formData.accountType === 'business'
? 'business-info'
: 'personal-info'
}
// Default sequential flow
return steps[currentStepIndex.value + 1].id
}
Multi-step forms improve complex data entry workflows. Implement progressive validation, draft saving, and navigation guards for the best user experience.