The Quasar stepper (q-stepper with q-step children) gives you the header, the transitions and the done and error icons of a multi-step form. What it doesn't give you is the logic: when may the user continue, which steps count as done, and can they jump ahead by clicking a header? This post builds a three-step checkout (shipping, delivery, review) and gets those parts right, with per-step validation, honest done states and a vertical layout on phones. Everything is checked against Quasar 2.35.2.
q-stepper holds the name of the active step in its v-model. Each q-step has a name (any value, often a number or a string) and a required title. Only the step whose name matches the model shows its content.
| Prop | On | What it does |
|---|---|---|
done | q-step | Shows the done icon and color. You decide when it's true. |
error | q-step | Shows the error icon and color. |
header-nav | both | Lets the user click a step header to go there. Off on the stepper by default, on for each step by default, and both must be on. |
vertical | q-stepper | Stacks the steps; content opens under each header. |
animated | q-stepper | Slides between steps. |
alternative-labels | q-stepper | Puts the icon above the title in the horizontal header. |
contracted | q-stepper | Hides header labels, for narrow screens. |
The stepper also has next(), previous() and goTo(name) methods. They skip disabled steps, which plain step.value++ does not. In the example below the step order lives in an array, so stepping through it does the same job.
One detail drives the whole design: a step that isn't active is unmounted. In 2.35.2, q-step renders its content only while it's active, unless you set keep-alive. Any input inside a step loses its local state when the user moves on. So the form data has to live in the parent, not in the step.
Here's the full component. Read it once, then the sections below explain the choices.
<script setup lang="ts">
import { reactive, ref } from 'vue'
type StepName = 'shipping' | 'delivery' | 'review'
const steps: StepName[] = ['shipping', 'delivery', 'review']
const step = ref<StepName>('shipping')
const failedStep = ref<StepName | null>(null)
const placing = ref(false)
const order = reactive({
name: '',
email: '',
address: '',
postcode: '',
speed: 'standard' as 'standard' | 'express',
notes: '',
})
const speedOptions = [
{ label: 'Standard (3-5 days)', value: 'standard' },
{ label: 'Express (next day)', value: 'express' },
]
const required = (value: string) => !!value.trim() || 'Required'
const validEmail = (value: string) => /^\S+@\S+\.\S+$/.test(value) || 'Enter a valid email'
function isDone(name: StepName) {
return steps.indexOf(name) < steps.indexOf(step.value)
}
async function advance() {
failedStep.value = null
const index = steps.indexOf(step.value)
if (index < steps.length - 1) {
step.value = steps[index + 1]!
return
}
placing.value = true
try {
await placeOrder({ ...order })
}
finally {
placing.value = false
}
}
function back() {
failedStep.value = null
step.value = steps[steps.indexOf(step.value) - 1]!
}
async function placeOrder(payload: typeof order) {
// POST to your API here
}
</script>
<template>
<q-form greedy @submit="advance" @validation-error="failedStep = step">
<q-stepper
v-model="step"
:vertical="$q.screen.lt.sm"
header-nav
animated
flat
bordered
>
<q-step
name="shipping"
title="Shipping"
icon="local_shipping"
:done="isDone('shipping')"
:error="failedStep === 'shipping'"
:header-nav="isDone('shipping')"
>
<div class="q-gutter-y-sm">
<q-input v-model="order.name" label="Full name" autocomplete="name" outlined :rules="[required]" />
<q-input v-model="order.email" label="Email" type="email" autocomplete="email" outlined :rules="[required, validEmail]" />
<q-input v-model="order.address" label="Address" autocomplete="street-address" outlined :rules="[required]" />
<q-input v-model="order.postcode" label="Postcode" autocomplete="postal-code" outlined :rules="[required]" />
</div>
<q-stepper-navigation>
<q-btn type="submit" color="primary" label="Continue" />
</q-stepper-navigation>
</q-step>
<q-step
name="delivery"
title="Delivery"
icon="schedule"
:done="isDone('delivery')"
:header-nav="isDone('delivery')"
>
<q-option-group v-model="order.speed" :options="speedOptions" color="primary" />
<q-input v-model="order.notes" label="Notes for the courier (optional)" outlined autogrow class="q-mt-md" />
<q-stepper-navigation>
<q-btn type="submit" color="primary" label="Continue" />
<q-btn flat color="primary" label="Back" class="q-ml-sm" @click="back" />
</q-stepper-navigation>
</q-step>
<q-step name="review" title="Review" icon="fact_check">
<p>{{ order.name }}, {{ order.address }}, {{ order.postcode }}</p>
<p>{{ order.speed === 'express' ? 'Express' : 'Standard' }} delivery</p>
<q-stepper-navigation>
<q-btn type="submit" color="primary" label="Place order" :loading="placing" />
<q-btn flat color="primary" label="Back" class="q-ml-sm" @click="back" />
</q-stepper-navigation>
</q-step>
</q-stepper>
</q-form>
</template>The trick is one q-form around the whole stepper, with the Continue buttons as type="submit".
When the form submits, q-form validates every Quasar field registered with it, and fields register when they mount and leave when they unmount. Since only the active step is mounted, the form only checks the fields of the current step. No refs per step, no list of which field belongs where. If the step is valid, @submit fires and advance() moves on. If not, @validation-error fires, the step gets its error state, and q-form moves focus to the first invalid field.
A few things that come with it:
q-btn defaults to type="button", so Back just calls back(). You shouldn't have to fix an email address to go back and change your name.greedy validates every field in the step at once, instead of stopping at the first invalid one. Users see all of their mistakes in one go.Two caveats. First, if you add keep-alive to the stepper, kept-alive steps stay mounted, their fields stay registered, and the form validates the steps you already passed too. Second, the final submit only checks the review step's fields. That's why the steps are linear (next section), and why the server must validate the whole order again anyway.
done is just a boolean, and the common example :done="step > 1" only works with numeric names. With string names, isDone() compares positions in the steps array: a step is done when it comes before the current one. Go back to Shipping and Delivery stops being done, which is correct, because you might be about to change the address.
The error state is set from @validation-error and cleared on the next successful submit or on Back. When several states apply, the header icon follows a priority in 2.35.2: active beats error, and error beats done. So the failed step keeps its active (edit) icon, but its header still gets the error class and the error color, as long as you don't set active-color (an explicit active color wins over the error color).
Customize the icons and colors on the stepper (for every step) or on one step: done-icon, done-color, error-icon, error-color, active-icon, active-color, inactive-icon, inactive-color. Use prefix="1" to show a step number instead of an icon while the step has no state.
header-nav on q-stepper makes every header clickable. For a checkout, that's too much: a user could click Review before entering an address. The fix is the per-step header-nav prop:
<q-step name="shipping" ... :header-nav="isDone('shipping')">A header is clickable only once its step is done, so users can jump back to any completed step, but never forward past the step they're on. The review step has no header-nav binding, and it doesn't need one: it can only be the active step or a step you haven't reached.
Since 2.25, the active header carries aria-current="step", each step's content is a group labelled by its title, and navigable headers are exposed as buttons that respond to Enter and Space. A step without header navigation gets no button role, because it isn't a control.
A horizontal header with three titles is fine on a laptop and cramped on a phone. :vertical="$q.screen.lt.sm" switches to vertical mode below 600px: each header sits above its content, and the next headers follow below.
You can switch at runtime. The Quasar docs say the steps and their content stay mounted across the switch, so a half-filled step keeps its values when someone rotates their phone. That's also why the navigation buttons are inside each step and not in the stepper's global #navigation slot: the global slot renders after all the steps, which in vertical mode puts the Continue button below the last header, far from the fields it submits.
If you'd rather keep it horizontal on small screens, :contracted="$q.screen.lt.md" hides the header labels and keeps the icons.
Why does my step lose its input values? The step's content unmounts when it isn't active. Keep the data in the parent (a reactive object or a store), or add keep-alive to the stepper.
How do I validate a step before going next? Wrap the stepper in a q-form, use type="submit" on the Continue buttons and advance in @submit. Only the active step's fields are mounted, so only they are checked.
Can I use numbers as step names? Yes. name can be any value, and the Quasar docs point out that a number is a name, not an index.
How do I show a message above each step? Use the stepper's #message slot. In horizontal mode it renders above each step's content; in vertical mode it renders above the steps.
The Steppers Block has two Free stepper headers built on q-stepper: five numbered steps, and four named steps with icons, both with alternative labels and header navigation. They're header-only, with no step content, so copy one and drop in the form, validation and navigation from this post.