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.


Speed up your responsive apps and websites with fully-featured, ready-to-use open-source admin panel templates—free to use and built for efficiency.


About the Author

Subscribe to our newsletter
Get early information about new products, product updates and blog posts.
Understanding the Difference Between NPX and NPM
Understanding the Difference Between NPX and NPM

How to Clone an Object in JavaScript
How to Clone an Object in JavaScript

How to set focus on an input field after rendering in React
How to set focus on an input field after rendering in React

JavaScript Template Literals: Complete Developer Guide
JavaScript Template Literals: Complete Developer Guide

Answers by CoreUI Core Team