How to build a wizard in Vue
Wizards guide users through complex processes by breaking them into sequential steps with data collection and validation at each stage. With over 12 years of Vue.js development experience since 2014 and as the creator of CoreUI, I’ve built wizard flows for onboarding, setup processes, and multi-step forms. A Vue wizard component combines step tracking, navigation controls, form validation, and data persistence across steps. This approach creates intuitive user experiences for complex workflows like account setup or product configuration.
Build a wizard component with step management, validation, and data persistence across the workflow.
<template>
<div class='wizard'>
<div class='wizard-header'>
<div
v-for='(step, index) in steps'
:key='index'
class='wizard-step-indicator'
:class='{
active: currentStep === index,
completed: currentStep > index,
disabled: currentStep < index
}'
>
<div class='step-number'>{{ index + 1 }}</div>
<div class='step-title'>{{ step.title }}</div>
</div>
</div>
<div class='wizard-body'>
<transition name='slide' mode='out-in'>
<div :key='currentStep' class='wizard-content'>
<h2>{{ steps[currentStep].title }}</h2>
<p class='step-description'>{{ steps[currentStep].description }}</p>
<slot :name='`step-${currentStep}`' :data='formData'></slot>
</div>
</transition>
</div>
<div class='wizard-footer'>
<button
v-if='currentStep > 0'
@click='previousStep'
class='btn btn-secondary'
>
Back
</button>
<div class='wizard-progress'>
Step {{ currentStep + 1 }} of {{ steps.length }}
</div>
<button
v-if='currentStep < steps.length - 1'
@click='nextStep'
class='btn btn-primary'
:disabled='!canProceed'
>
Continue
</button>
<button
v-if='currentStep === steps.length - 1'
@click='submit'
class='btn btn-success'
:disabled='!canProceed'
>
Complete
</button>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
steps: {
type: Array,
required: true
},
canProceed: {
type: Boolean,
default: true
}
})
const emit = defineEmits(['step-change', 'submit', 'update:formData'])
const currentStep = ref(0)
const formData = ref({})
const nextStep = () => {
if (currentStep.value < props.steps.length - 1 && props.canProceed) {
currentStep.value++
emit('step-change', {
step: currentStep.value,
direction: 'forward',
data: formData.value
})
}
}
const previousStep = () => {
if (currentStep.value > 0) {
currentStep.value--
emit('step-change', {
step: currentStep.value,
direction: 'back',
data: formData.value
})
}
}
const submit = () => {
if (props.canProceed) {
emit('submit', formData.value)
}
}
const updateData = (key, value) => {
formData.value[key] = value
emit('update:formData', formData.value)
}
defineExpose({ currentStep, formData, nextStep, previousStep })
</script>
<style scoped>
.wizard {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.wizard-header {
display: flex;
justify-content: space-between;
margin-bottom: 40px;
position: relative;
}
.wizard-step-indicator {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
position: relative;
opacity: 0.5;
}
.wizard-step-indicator.active {
opacity: 1;
}
.wizard-step-indicator.completed {
opacity: 1;
}
.wizard-step-indicator:not(:last-child)::after {
content: '';
position: absolute;
top: 20px;
left: 50%;
width: 100%;
height: 2px;
background: #ddd;
z-index: -1;
}
.wizard-step-indicator.completed:not(:last-child)::after {
background: #28a745;
}
.step-number {
width: 40px;
height: 40px;
border-radius: 50%;
background: #e0e0e0;
color: #666;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
margin-bottom: 8px;
}
.wizard-step-indicator.active .step-number {
background: #007bff;
color: white;
}
.wizard-step-indicator.completed .step-number {
background: #28a745;
color: white;
}
.step-title {
font-size: 12px;
text-align: center;
color: #666;
}
.wizard-body {
min-height: 300px;
margin-bottom: 30px;
}
.wizard-content {
background: white;
padding: 30px;
border: 1px solid #ddd;
border-radius: 8px;
}
.step-description {
color: #666;
margin-bottom: 20px;
}
.wizard-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
}
.wizard-progress {
color: #666;
font-size: 14px;
}
.btn {
padding: 10px 24px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-success {
background: #28a745;
color: white;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.slide-enter-active,
.slide-leave-active {
transition: all 0.3s ease;
}
.slide-enter-from {
opacity: 0;
transform: translateX(30px);
}
.slide-leave-to {
opacity: 0;
transform: translateX(-30px);
}
</style>
Use it in your component:
<template>
<Wizard
:steps='wizardSteps'
:can-proceed='isCurrentStepValid'
@step-change='handleStepChange'
@submit='handleSubmit'
>
<template #step-0='{ data }'>
<input v-model='data.name' placeholder='Full Name'>
<input v-model='data.email' placeholder='Email'>
</template>
<template #step-1='{ data }'>
<input v-model='data.company' placeholder='Company'>
<input v-model='data.role' placeholder='Job Role'>
</template>
<template #step-2='{ data }'>
<h3>Review Your Information</h3>
<p>Name: {{ data.name }}</p>
<p>Email: {{ data.email }}</p>
<p>Company: {{ data.company }}</p>
<p>Role: {{ data.role }}</p>
</template>
</Wizard>
</template>
<script setup>
import { ref, computed } from 'vue'
import Wizard from './Wizard.vue'
const wizardSteps = ref([
{ title: 'Personal Info', description: 'Enter your personal details' },
{ title: 'Professional Info', description: 'Tell us about your work' },
{ title: 'Review', description: 'Confirm your information' }
])
const isCurrentStepValid = computed(() => {
// Add validation logic
return true
})
const handleStepChange = ({ step, direction, data }) => {
console.log('Step changed:', step, direction)
// Save progress, track analytics, etc.
}
const handleSubmit = (data) => {
console.log('Wizard completed:', data)
// Submit to API
}
</script>
Best Practice Note
Store wizard data in parent component to survive step navigation. Validate each step before allowing progression. Save progress to localStorage for multi-session workflows. Emit events for step changes to track analytics. Use transitions for smooth step navigation. Allow users to go back and edit previous steps. This is the wizard pattern we use in CoreUI for Vue—building complex onboarding flows, multi-step configurations, and guided setup processes for enterprise applications with clear progress indication and validation at each stage.



