How to build a stepper in Vue

Multi-step forms and wizards guide users through complex processes by breaking them into manageable steps with clear progress indication. As the creator of CoreUI with over 12 years of Vue.js experience since 2014, I’ve built stepper components for onboarding flows, checkout processes, and configuration wizards. A Vue stepper component requires reactive state for the current step, methods for navigation, and validation before allowing progression. This approach creates an intuitive user experience for multi-stage workflows.

Build a stepper component with step tracking, navigation controls, and content slots for each step.

<template>
  <div class='stepper'>
    <div class='stepper-header'>
      <div
        v-for='(step, index) in steps'
        :key='index'
        class='stepper-step'
        :class='{
          active: currentStep === index,
          completed: currentStep > index
        }'
      >
        <div class='step-circle'>
          <span v-if='currentStep > index'>✓</span>
          <span v-else>{{ index + 1 }}</span>
        </div>
        <div class='step-label'>{{ step.title }}</div>
        <div v-if='index < steps.length - 1' class='step-line'></div>
      </div>
    </div>

    <div class='stepper-content'>
      <slot :name='`step-${currentStep}`'></slot>
    </div>

    <div class='stepper-actions'>
      <button
        v-if='currentStep > 0'
        @click='previousStep'
        class='btn btn-secondary'
      >
        Previous
      </button>

      <button
        v-if='currentStep < steps.length - 1'
        @click='nextStep'
        class='btn btn-primary'
        :disabled='!canProceed'
      >
        Next
      </button>

      <button
        v-if='currentStep === steps.length - 1'
        @click='finish'
        class='btn btn-success'
        :disabled='!canProceed'
      >
        Finish
      </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', 'finish'])

const currentStep = ref(0)

const nextStep = () => {
  if (currentStep.value < props.steps.length - 1 && props.canProceed) {
    currentStep.value++
    emit('step-change', currentStep.value)
  }
}

const previousStep = () => {
  if (currentStep.value > 0) {
    currentStep.value--
    emit('step-change', currentStep.value)
  }
}

const finish = () => {
  if (props.canProceed) {
    emit('finish')
  }
}

defineExpose({ currentStep, nextStep, previousStep })
</script>

<style scoped>
.stepper {
  width: 100%;
  padding: 20px;
}

.stepper-header {
  display: flex;
  justify-content: space-between;
  margin-bottom: 40px;
  position: relative;
}

.stepper-step {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  position: relative;
}

.step-circle {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background: #e0e0e0;
  color: #666;
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: bold;
  z-index: 2;
  transition: all 0.3s;
}

.stepper-step.active .step-circle {
  background: #007bff;
  color: white;
}

.stepper-step.completed .step-circle {
  background: #28a745;
  color: white;
}

.step-label {
  margin-top: 10px;
  font-size: 14px;
  color: #666;
  text-align: center;
}

.stepper-step.active .step-label {
  color: #007bff;
  font-weight: 600;
}

.step-line {
  position: absolute;
  top: 20px;
  left: 50%;
  width: 100%;
  height: 2px;
  background: #e0e0e0;
  z-index: 1;
}

.stepper-step.completed .step-line {
  background: #28a745;
}

.stepper-content {
  min-height: 200px;
  padding: 20px;
  border: 1px solid #ddd;
  border-radius: 4px;
  margin-bottom: 20px;
}

.stepper-actions {
  display: flex;
  justify-content: space-between;
  gap: 10px;
}

.btn {
  padding: 10px 20px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.2s;
}

.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;
}
</style>

Use it in your component:

<template>
  <Stepper
    :steps='steps'
    :can-proceed='isStepValid'
    @step-change='handleStepChange'
    @finish='handleFinish'
  >
    <template #step-0>
      <h3>Personal Information</h3>
      <input v-model='formData.name' placeholder='Name'>
      <input v-model='formData.email' placeholder='Email'>
    </template>

    <template #step-1>
      <h3>Address</h3>
      <input v-model='formData.address' placeholder='Address'>
      <input v-model='formData.city' placeholder='City'>
    </template>

    <template #step-2>
      <h3>Review</h3>
      <p>Name: {{ formData.name }}</p>
      <p>Email: {{ formData.email }}</p>
      <p>Address: {{ formData.address }}</p>
    </template>
  </Stepper>
</template>

<script setup>
import { ref, computed } from 'vue'
import Stepper from './Stepper.vue'

const steps = ref([
  { title: 'Personal Info' },
  { title: 'Address' },
  { title: 'Review' }
])

const formData = ref({
  name: '',
  email: '',
  address: '',
  city: ''
})

const isStepValid = computed(() => {
  // Add validation logic
  return true
})

const handleStepChange = (step) => {
  console.log('Changed to step:', step)
}

const handleFinish = () => {
  console.log('Form submitted:', formData.value)
}
</script>

Best Practice Note

Use the canProceed prop to enable/disable next button based on current step validation. Store form data in parent component and validate before allowing progression. Use defineExpose to allow parent components to programmatically control steps. Add visual feedback for completed vs current vs upcoming steps. Consider saving progress to localStorage for multi-session workflows. This is the stepper pattern we use in CoreUI for Vue—building complex onboarding and configuration wizards with clear progress indication and validation at each step for enterprise applications.


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.
How to concatenate a strings in JavaScript?
How to concatenate a strings in JavaScript?

Mastering JavaScript List Comprehension: The Ultimate Guide
Mastering JavaScript List Comprehension: The Ultimate Guide

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

How to Merge Objects in JavaScript
How to Merge Objects in JavaScript

Answers by CoreUI Core Team