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.
Features
Section titled “Features”- 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.
Anatomy
Section titled “Anatomy”Creating a Stepped Form Flow
Section titled “Creating a Stepped Form Flow”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
Section titled “Validation”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.
HTML Constraints
Section titled “HTML Constraints”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>
Schema Validation
Section titled “Schema Validation”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>
Navigation with Named Steps
Section titled “Navigation with Named Steps”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);
Non-linear Flow
Section titled “Non-linear Flow”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.
Querying the current step
Section titled “Querying the current 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>
Examples
Section titled “Examples”These are some examples of number fields built with Formwerk.
useStepFormFlow
Section titled “useStepFormFlow”These are the props that can be passed to the useStepFormFlow
composable.
Name | Type | Description |
---|---|---|
disabled | Whether the form is disabled. | |
disableHtmlValidation | Whether HTML5 validation should be disabled for this form. | |
id | The form's unique identifier. | |
initialDirty | The initial dirty state for form fields. | |
initialTouched | The initial touched state for form fields. | |
initialValues | ||
nextLabel | The label for the next button. Will be used if the button has no text content. | |
previousLabel | The label for the previous button. Will be used if the button has no text content. | |
scrollToInvalidFieldOnSubmit | Whether the form should scroll to the first invalid field on invalid submission. |
Returns
Section titled “Returns”These are the values that are returned by the useStepFormFlow
composable.
Name | Type | Description |
---|---|---|
context | ||
currentStep | The 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 | ||
isLastStep | Whether the current step is the last step in the flow. | |
isSubmitAttempted | ||
isSubmitting | ||
isTouched | ||
isValid | ||
next | Activates 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. |
previous | Activates 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 | |
steps | The registered steps in the flow. | |
submitAttemptsCount | ||
validate | () => Promise<FormValidationResult<TInput>> | |
values | type_fest_source_partial_deep244._PartialDeep<TInput, { recurseIntoArrays: false; allowUndefinedInNonTupleArrays: true; }> | |
wasSubmitted |
Slot Props
Section titled “Slot Props”<FormStep />
Section titled “<FormStep />”These are the props that can be passed to the <FormStep />
component.
Name | Type | Description |
---|---|---|
name | ||
schema |
StepResolveContext
Section titled “StepResolveContext”This is the object that gets passed to the onBeforeStepResolve
function.
Name | Type | Description |
---|---|---|
currentIndex | The index of the current step in the flow. | |
currentStep | The current step in the flow. | |
direction | The values of the form. | |
done | Fires the done event, use it to "submit" the entire collected data across all steps. | |
isLastStep | Whether the current step is the last step in the flow. | |
next | Resolves the next step in the flow. | |
steps | The steps in the flow. | |
values | The values of the form. | |
visitedSteps | The visited steps in the flow. | |
wait | Tells the step resolver to wait for an external controller to change the next step. |