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.



