Skip to content

Stepped Forms

Stepped form flows are a very common pattern around the web. They are used to guide users through a form by dividing the form into multiple steps. Typically the user navigates the steps in a linear way but not necessarily.

Aside from collecting information from users, they serve many purposes, common ones being:

  • Split up large forms into smaller, more manageable parts.
  • Conditionally navigate through steps based on user input.

Before we dive into the details, let’s clear up some terminology:

  • Linear flow: The user is required to complete all steps in order, every step is non-skippable.
  • Non-linear flow: The user can skip steps, some steps might be constrained by previous steps.
  • Automatic navigation through steps in a linear flow.
  • Custom navigation through steps in a non-linear flow.
  • Schema validation for steps and validates steps before moving to the next one.
  • Accessible next/previous navigation buttons.
  • Custom step names.
  • Step Navigation API.
Step 1
Field 1
Value
Field 2
Value
<FormStep />
Previous
Previous Step Button
Next
Next Step Button
Form

To create a stepped form, you can use the useStepFormFlow composable. Just like fields, the useStepFormFlow composable returns values that should be bound to the anatomy elements.

The useStepFormFlow composable returns a fully featured-API that allows you to easily organize your form into steps and respond to whenever the user has finished the last step.

<script setup lang="ts">
import { useStepFormFlow } from '@formwerk/core';
import TextField from './TextField.vue';
import Checkbox from './Checkbox.vue';
const {
formProps,
nextButtonProps,
previousButtonProps,
isLastStep,
FormStep,
onDone,
} = useStepFormFlow();
onDone((data) => {
console.log(JSON.stringify(data.toObject(), null, 2));
});
</script>
<template>
<form v-bind="formProps">
<FormStep>
<div>Step 1</div>
<TextField name="name" label="Name" />
<TextField name="email" label="Email" />
</FormStep>
<FormStep>
<div>Step 2</div>
<TextField name="city" label="City" />
<TextField name="zip" label="ZIP" />
</FormStep>
<FormStep>
<div>Step 3</div>
<Checkbox name="terms" label="I agree to the terms and conditions" />
</FormStep>
<button v-bind="previousButtonProps">⬅️ Previous</button>
<button v-bind="nextButtonProps">
{{ isLastStep ? 'Submit' : 'Next ➡️' }}
</button>
</form>
</template>

Validation in stepped forms works exactly the same way in regular forms, when a step is visited the user needs to make sure all the validated fields are valid before proceeding to the next step, but unlike regular forms, useStepFormFlow does not accept a schema prop. This is because form-level validation does not make sense in this context.

Still, Formwerk’s stepped forms do support both HTML constraints and Standard Schema validations.

Here is the same example as before, but we added some required and type attributes to the fields.

<script setup lang="ts">
import { useStepFormFlow } from '@formwerk/core';
import TextField from './TextField.vue';
import Checkbox from './Checkbox.vue';
const {
formProps,
nextButtonProps,
previousButtonProps,
isLastStep,
FormStep,
onDone,
} = useStepFormFlow();
onDone((data) => {
console.log(JSON.stringify(data.toObject(), null, 2));
});
</script>
<template>
<form v-bind="formProps">
<FormStep>
<div>Step 1</div>
<TextField name="name" label="Name" required />
<TextField name="email" label="Email" required type="email" />
</FormStep>
<FormStep>
<div>Step 2</div>
<TextField name="city" label="City" required />
<TextField name="zip" label="ZIP" required />
</FormStep>
<FormStep>
<div>Step 3</div>
<Checkbox
name="terms"
label="I agree to the terms and conditions"
required
/>
</FormStep>
<button v-bind="previousButtonProps">⬅️ Previous</button>
<button v-bind="nextButtonProps">
{{ isLastStep ? 'Submit' : 'Next ➡️' }}
</button>
</form>
</template>

For more complex needs, you can use Standard Schema to validate your steps. The FormStep component accepts a schema prop that can be used to validate the step’s fields.

Here is the same example as before, but with Standard Schema validations.

<script setup lang="ts">
import { z } from 'zod';
import { useStepFormFlow } from '@formwerk/core';
import TextField from './TextField.vue';
import Checkbox from './Checkbox.vue';
const step1 = z.object({
name: z.string().min(1),
email: z.string().email(),
});
const step2 = z.object({
city: z.string().min(1),
zip: z.string().min(1),
});
const step3 = z.object({
terms: z.boolean().refine((val) => val, {
message: 'You must agree to the terms and conditions',
}),
});
const {
formProps,
nextButtonProps,
previousButtonProps,
isLastStep,
FormStep,
onDone,
} = useStepFormFlow();
onDone((data) => {
console.log(JSON.stringify(data.toObject(), null, 2));
});
</script>
<template>
<form v-bind="formProps">
<FormStep :schema="step1">
<div>Step 1</div>
<TextField name="name" label="Name" />
<TextField name="email" label="Email" />
</FormStep>
<FormStep :schema="step2">
<div>Step 2</div>
<TextField name="city" label="City" />
<TextField name="zip" label="ZIP" />
</FormStep>
<FormStep :schema="step3">
<div>Step 3</div>
<Checkbox name="terms" label="I agree to the terms and conditions" />
</FormStep>
<button v-bind="previousButtonProps">⬅️ Previous</button>
<button v-bind="nextButtonProps">
{{ isLastStep ? 'Submit' : 'Next ➡️' }}
</button>
</form>
</template>

You can name your steps by passing a name prop to the FormStep component and this allows them to be programmatically navigate-able.

You can navigate to a step by passing its name to the goToStep function. Try filling out the steps and then clicking the buttons to navigate through them.

<script setup lang="ts">
import { z } from 'zod';
import { useStepFormFlow } from '@formwerk/core';
import TextField from './TextField.vue';
import Checkbox from './Checkbox.vue';
const {
formProps,
nextButtonProps,
previousButtonProps,
isLastStep,
FormStep,
onDone,
goToStep,
} = useStepFormFlow();
const info = z.object({
name: z.string().min(1),
email: z.string().email(),
});
const address = z.object({
city: z.string().min(1),
zip: z.string().min(1),
});
const terms = z.object({
terms: z.boolean().refine((val) => val, {
message: 'You must agree to the terms and conditions',
}),
});
onDone((data) => {
console.log(JSON.stringify(data.toObject(), null, 2));
});
</script>
<template>
<div>
<button @click="goToStep('info')">Info</button>
<button @click="goToStep('address')">Address</button>
<button @click="goToStep('terms')">Terms</button>
</div>
<form v-bind="formProps">
<FormStep name="info" :schema="info">
<div>Step 1</div>
<TextField name="name" label="Name" />
<TextField name="email" label="Email" />
</FormStep>
<FormStep name="address" :schema="address">
<div>Step 2</div>
<TextField name="city" label="City" />
<TextField name="zip" label="ZIP" />
</FormStep>
<FormStep name="terms" :schema="terms">
<div>Step 3</div>
<Checkbox name="terms" label="I agree to the terms and conditions" />
</FormStep>
<button v-bind="previousButtonProps">⬅️ Previous</button>
<button v-bind="nextButtonProps">
{{ isLastStep ? 'Submit' : 'Next ➡️' }}
</button>
</form>
</template>

By default you cannot navigate to a step unless it has been already visited. You can override this by passing the force prop to the goToStep function call, that way you will navigate to the step regardless if the user has visited it or not.

goToStep('info', { force: true });

You can also navigate to a step by passing its index to the goToStep function.

goToStep(1);

Sometimes you might wish to allow the user to skip steps, or to navigate to a step based on a condition or a current value.

You can do by using the onBeforeStepResolve function returned by the useStepFormFlow composable to register a callback. The callback should return the name or the index of the next step to navigate to.

In the next example we will create a form that allows the user to skip the school step if they are not a student in either direction.

<script setup lang="ts">
import { z } from 'zod';
import { useStepFormFlow } from '@formwerk/core';
import TextField from './TextField.vue';
import Checkbox from './Checkbox.vue';
import Switch from './Switch.vue';
const {
formProps,
nextButtonProps,
previousButtonProps,
isLastStep,
FormStep,
onDone,
onBeforeStepResolve,
} = useStepFormFlow();
const info = z.object({
name: z.string().min(1),
isStudent: z.boolean().default(false),
});
const school = z.object({
schoolName: z.string().min(1),
});
const address = z.object({
city: z.string().min(1),
zip: z.string().min(1),
});
onBeforeStepResolve(async ({ currentStep, values, next, direction }) => {
const nextStep = await next();
if (nextStep.name === 'school' && !values.isStudent) {
return direction === 'next' ? 'address' : 'info';
}
return next();
});
onDone((data) => {
console.log(JSON.stringify(data.toObject(), null, 2));
});
</script>
<template>
<form v-bind="formProps">
<FormStep name="info" :schema="info">
<TextField name="name" label="Name" />
<Switch name="isStudent" label="Are you a student?" />
</FormStep>
<FormStep name="school" :schema="school">
<TextField name="schoolName" label="Your school name" />
</FormStep>
<FormStep name="address" :schema="address">
<TextField name="city" label="City" />
<TextField name="zip" label="ZIP" />
</FormStep>
<button v-bind="previousButtonProps">⬅️ Previous</button>
<button v-bind="nextButtonProps">
{{ isLastStep ? 'Submit' : 'Next ➡️' }}
</button>
</form>
</template>

For large forms it could be hard to check the direction of the navigation for each step, so it is recommended to use something like state machines to determine the next step.

You can query which step is currently active by using the isCurrentStep function. The next example uses the isCurrentStep function to the buttons to highlight the active step by adding a data-active attribute.

<script setup lang="ts">
import { z } from 'zod';
import { useStepFormFlow } from '@formwerk/core';
import TextField from './TextField.vue';
import Checkbox from './Checkbox.vue';
const {
formProps,
nextButtonProps,
previousButtonProps,
isLastStep,
FormStep,
onDone,
goToStep,
isCurrentStep,
} = useStepFormFlow();
const info = z.object({
name: z.string().min(1),
email: z.string().email(),
});
const address = z.object({
city: z.string().min(1),
zip: z.string().min(1),
});
const terms = z.object({
terms: z.boolean().refine((val) => val, {
message: 'You must agree to the terms and conditions',
}),
});
onDone((data) => {
console.log(JSON.stringify(data.toObject(), null, 2));
});
</script>
<template>
<div>
<button :data-active="isCurrentStep('info')" @click="goToStep('info')">
Info
</button>
<button
:data-active="isCurrentStep('address')"
@click="goToStep('address')"
>
Address
</button>
<button :data-active="isCurrentStep('terms')" @click="goToStep('terms')">
Terms
</button>
</div>
<form v-bind="formProps">
<FormStep name="info" :schema="info">
<div>Step 1</div>
<TextField name="name" label="Name" />
<TextField name="email" label="Email" />
</FormStep>
<FormStep name="address" :schema="address">
<div>Step 2</div>
<TextField name="city" label="City" />
<TextField name="zip" label="ZIP" />
</FormStep>
<FormStep name="terms" :schema="terms">
<div>Step 3</div>
<Checkbox name="terms" label="I agree to the terms and conditions" />
</FormStep>
<button v-bind="previousButtonProps">⬅️ Previous</button>
<button v-bind="nextButtonProps">
{{ isLastStep ? 'Submit' : 'Next ➡️' }}
</button>
</form>
</template>
<style>
button[data-active='true'] {
background-color: #000;
color: #fff;
}
</style>

These are some examples of number fields built with Formwerk.

These are the props that can be passed to the useStepFormFlow composable.

NameTypeDescription
disabledWhether the form is disabled.
disableHtmlValidationWhether HTML5 validation should be disabled for this form.
idThe form's unique identifier.
initialDirtyThe initial dirty state for form fields.
initialTouchedThe initial touched state for form fields.
initialValues
nextLabelThe label for the next button. Will be used if the button has no text content.
previousLabelThe label for the previous button. Will be used if the button has no text content.
scrollToInvalidFieldOnSubmitWhether the form should scroll to the first invalid field on invalid submission.

These are the values that are returned by the useStepFormFlow composable.

NameTypeDescription
context
currentStepThe current step in the flow.
displayError
formProps
Ref<{ novalidate: boolean; onSubmit: (e: Event) =>
Props to bind to the form flow element, either a form or any other HTML element.
FormStep
vue56.DefineComponent<{ name?: any; as?: any; schema?: any; }, () => vue56.VNode<vue56.RendererNode, vue56.RendererElement, { [key: string]: any; }>, {}, {}, {}, vue56.ComponentOptionsMixin, vue56.ComponentOptionsMixin, {}, string, vue56.PublicProps, any, {}, {}, {}, {}, string, vue56.ComponentProvideOptions, true, ...
The FormStep component to be used within the form flow.
getError
getErrors
getIssues
getStepValue
(segmentId: string | number) => type_fest_source_partial_deep244._PartialDeep<TInput, { recurseIntoArrays: false; allowUndefinedInNonTupleArrays: true; }>
Gets the values of a step.
getSubmitError
getSubmitErrors
getValue
TPath extends Path<TInput>>(path: TPath) => PathValue<TInput, TPath
goToStep
(segmentId: string | number, opts?: { force?: true; }) => boolean
Activates the given step in the flow, if the step does not exist it won't have an effect. It also won't proceed to next steps if they have never been activated before.
handleReset
(afterReset?: () => any) => (e?: Event) => Promise<void>
handleSubmit
(onSubmit: (values: ConsumableData<TInput>) => any) => (e?: Event) => Promise<void>
isCurrentStep
(segmentId: string | number) => boolean
Whether the given step is active (i.e. the current step).
isDirty
isDisabled
isLastStepWhether the current step is the last step in the flow.
isSubmitAttempted
isSubmitting
isTouched
isValid
nextActivates the step in the flow, if it is already at the last step it will trigger the `onDone` handler.
nextButtonProps
Ref<{ [x: string]: unknown; type: "button"; role: string; tabindex: string; }>
Props to bind to the next button.
onBeforeStepResolve
(resolver: StepResolver<TInput>) => void
A callback to be called before a step is resolved.
onDone
(handler: (payload: ConsumableData<TInput>) => void, vm?: vue56.ComponentInternalInstance) => () => void
A callback to be called when the all the steps are completed and the form is submitted.
previousActivates the previous step in the flow if available.
previousButtonProps
Ref<{ [x: string]: unknown; type: "button"; role: string; tabindex: string; }>
Props to bind to the previous button.
reset
{ (): Promise<void>; (state: Partial<ResetState<TInput>>, opts?: SetValueOptions): Promise<void>; <TPath>(path: TPath): Promise<void>; <TPath extends Path<TInput>>(path: TPath, state: Partial<...>, opts?: SetValueOptions): Promise<...>; }
setErrors
{ <TPath>(path: TPath, message: type_fest267.Arrayable<string>): void; <TPath extends Path<TInput>>(issues: {}): void; }
setTouched
{ (value: boolean): void; <TPath>(path: TPath, value: boolean): void; }
setValue
<TPath>(path: TPath, value: PathValue<TInput, TPath>) => void
setValues
(values: type_fest_source_partial_deep244._PartialDeep<TInput, { recurseIntoArrays: false; allowUndefinedInNonTupleArrays: true; }>, opts?: SetValueOptions) => void
stepsThe registered steps in the flow.
submitAttemptsCount
validate
() => Promise<FormValidationResult<TInput>>
values
type_fest_source_partial_deep244._PartialDeep<TInput, { recurseIntoArrays: false; allowUndefinedInNonTupleArrays: true; }>
wasSubmitted

These are the props that can be passed to the <FormStep /> component.

NameTypeDescription
name
schema

This is the object that gets passed to the onBeforeStepResolve function.

NameTypeDescription
currentIndexThe index of the current step in the flow.
currentStepThe current step in the flow.
directionThe values of the form.
doneFires the done event, use it to "submit" the entire collected data across all steps.
isLastStepWhether the current step is the last step in the flow.
nextResolves the next step in the flow.
stepsThe steps in the flow.
valuesThe values of the form.
visitedStepsThe visited steps in the flow.
waitTells the step resolver to wait for an external controller to change the next step.