Starting with 1.0.0-beta.15
FormKit ships an official 1st-party plugin that creates a multi-step
input type.
While there is still value in understanding how to build a multi-step input on your own — if you're looking for the easiest way to use a multi-step input in your project check out the official FormKit multi-step plugin — it's free and open-source!
Few interactions on the web cause as much displeasure as being confronted with a large, intimidating form. Multi-step forms — sometimes called "wizards" — can alleviate this pain by breaking a large form into smaller approachable steps — but they can also be complicated to build.
In this guide, we'll walk through building a multi-step form with FormKit and see how we can provide an elevated user experience with minimal code. Let's get started!
This guide assumes you are are familiar with the Vue Composition API.
Let's begin by laying out the requirements for our multi-part form:
First, let's create a basic form without steps so we have content to work with. Our example will be a pretend application to receive a grant, so we'll organize the form into 3 sections — "Contact Info", "Organization Info", and "Application". These will become the full form steps later.
We'll include a mix of validation rules for each input, and limit each section to 1 question for now until we have the full structure in place. Lastly, for the purposes of this guide, we'll output the collected form data at the bottom of each example:
Now that we have a defined structure, let's break the form into distinct sections.
To start, let's wrap each section of inputs with a group (<FormKit type="group" />
) so we can validate each group independently. FormKit groups are powerful because they are aware of the validation state of all their descendants without affecting the markup of your form.
A group itself becomes valid when all its children (and their children) are valid:
<!-- Only showing a single group here for brevity -->
<FormKit type="group" name: "contactInfo">
<FormKit type="email" label="*Email address" validation="required|email" />
</FormKit>
...
In our case, we're also going to want wrapping HTML. Let's put each group into a "step" section which we can conditionally show and hide:
<!-- Only showing a single group here for brevity -->
<section v-show="step === 'contactInfo'">
<FormKit type="group" name: "contactInfo">
<FormKit type="email" label="*Email address" validation="required|email" />
</FormKit>
</section>
...
Next, let's introduce some navigation UI so we can toggle between each step:
// for now, manually set step names
const stepNames = ['contactInfo', 'organizationInfo', 'application']
<!-- Set up tab-navigation UI. On click, change step -->
<ul class="steps">
<li
v-for="stepName in stepNames"
class="step"
@click="step = stepName"
:data-step-active="step === stepName"
>
{{ camel2title(panel) }}
</li>
</ul>
Here's what it looks like put together:
The CSS for multi-step forms — such as the tabs in this example — is not included in the default Genesis theme. Styles were custom-written for this example and you will need to provide your own.
It's starting to look like a real multi-step form! There's more work to be done though as we've got a few issues:
Let's address the first issue.
FormKit already tracks group
validity out-of-the-box. We'll just need to capture this data so we can use it in our UI.
One important concept to remember about FormKit is that every <FormKit>
component has a matching core node, which itself has a reactive node.context
object. This context
object tracks the validity of the node in context.state.valid
. As mentioned above, a group
becomes valid when all its descendants are valid. With that in mind, let's build up an object that stores the reactive validity of each of the groups.
We'll leverage FormKit's plugin functionality to do this job. While the term "plugin" may sound intimidating, plugins in FormKit are just setup functions that are called when a node is created. Plugins are inherited by all descendants (such as children within a group).
Here's our custom plugin, called stepPlugin
:
// our plugin and our template code will make use of 'steps'
const steps = reactive({})
const stepPlugin = (node) => {
// only runs for <FormKit type="group" />
if (node.props.type == 'group') {
// build up our steps object
steps[node.name] = steps[node.name] || {}
// add the current group's reactive validity
node.on('created', () => {
steps[node.name].valid = toRef(node.context.state, 'valid')
})
// Stop plugin inheritance to descendant nodes.
// We only care about the the top-level groups
// that represent the steps.
return false
}
}
The resulting steps
reactive object from our plugin above looks like this:
{
contactInfo: { valid: false },
organizationInfo: { valid: false }
application: { valid: false }
}
To use our plugin, we'll add it to our root form <FormKit type="form" />
. This means that every top-level group in our form will inherit the plugin:
<FormKit type="form" :plugins="[stepPlugin]"> ... rest of the form </FormKit>
Now that our template has real-time access to each group's validity state via our plugin, let's write the UI to show this data in the step navigation bar.
We also no longer need to manually define our steps since our plugin is dynamically storing the name of all groups in the steps
object. Let's add a data-step-valid="true"
attribute to each step if it's valid so we can target with CSS:
<ul class="steps">
<li
v-for="(step, stepName) in steps"
class="step"
@click="activeStep = stepName"
:data-step-valid="step.valid"
:data-step-active="activeStep === stepName"
>
{{ camel2title(stepName) }}
</li>
</ul>
With these updates, our form is now capable of informing a user when they have correctly filled out all of the fields in a given step!
We'll also make a few other improvements:
activeStep
.Showing errors is more nuanced. Though the user may not be aware, there are actually 2 types of errors we need to handle and communicate to the user:
messages
of type validation
)messages
of type error
)FormKit uses its message store to track both of these types of errors/messages.
With our plugin already in place, it's relatively simple to add tracking for both:
const stepPlugin = (node) => {
...
// Store or update the count of blocking validation messages.
// FormKit emits the "count:blocking" event (with the count) each time
// the count changes.
node.on('count:blocking', ({ payload: count }) => {
steps[node.name].blockingCount = count
})
// Store or update the count of backend error messages.
node.on('count:errors', ({ payload: count }) => {
steps[node.name].errorCount = count
})
...
}
FormKit makes a distinction between frontend validation messages (messages
of type validation
), and errors (messages
of type error
).
Let's update our example to show both types of errors with the following requirements:
Since "blurring a group" doesn't exist in HTML, we'll introduce it in our plugin with an array called visitedSteps
. Here's the relevant code:
import { watch } from 'vue'
import { getNode, createMessage } from '@formkit/core'
const stepPlugin = (node) => {
...
const activeStep = ref('')
const visitedSteps = ref([]) // track visited steps
// Watch our activeStep and store visited steps
watch(activeStep, (newStep, oldStep) => {
if (oldStep && !visitedSteps.value.includes(oldStep)) {
visitedSteps.value.push(oldStep)
}
// Trigger showing validation on fields if a group has been visited
visitedSteps.value.forEach((step) => {
const node = getNode(step)
// the node.walk() method walks through all the descendants of the current node
// and executes the provided function.
node.walk((n) => {
n.store.set(
createMessage({
key: 'submitted',
value: true,
visible: false
})
)
})
})
})
...
}
You might be wondering why we are walking all of the descendants of a given step (node.walk()
) and creating messages with a key of submitted
and value of true
? When a user attempts to submit a form, this is how FormKit informs itself that all inputs are in a submitted
state. In this state, FormKit forces any blocking validation messages to appear. We are manually triggering the same thing in our "group blur" event.
We'll use the same UI for both types of errors since end-users don't really care about the distinction. Here's our updated step HTML, which outputs a red bubble with the sum total of the errors errorCount
+ blockingCount
:
<li v-for="(step, stepName) in steps" class="step" ...>
<span
v-if="checkStepValidity(stepName)"
class="step--errors"
v-text="step.errorCount + step.blockingCount"
/>
{{ camel2title(stepName) }}
</li>
We are almost to the finish line! Here's our current form — which can now tell a user when they have properly or improperly filled out each step:
The last piece of the puzzle is submitting the form and handling any errors we receive from the backend server. We'll fake the backend for the purposes of this guide.
We submit the form by adding an @submit
handler to the <FormKit type="form">
:
<FormKit type="form" @submit="submitApp"> ... rest of form</FormKit>
And here's our submit handler:
const submitApp = async (formData, node) => {
try {
const res = await axios.post(formData)
node.clearErrors()
alert('Your application was submitted successfully!')
} catch (err) {
node.setErrors(err.formErrors, err.fieldErrors)
}
}
Notice that FormKit passes our submit handler 2 helpful arguments: the form's data in a single request-ready object (which we're calling formData
), and the form's underlying core node
, which we can use to clear errors or set any returned errors using the node.clearErrors()
and node.setErrors()
helpers, respectively.
setErrors()
takes 2 arguments: form-level errors and field-specific errors. Our fake backend returns the err
response which we use to set any errors.
So, what happens if the user is on step 3 (Application) when they submit, and there are field-level errors on a hidden step? Thankfully, so long as the nodes exist the DOM, FormKit is able place these errors appropriately. This is why we used a v-show
for the steps instead of v-if
— The DOM node needs to exist in order to have errors set on the corresponding FormKit node.
And Voilà! 🎉 We are finished! In addition to our submit handler, we've added some more UI and UX flourishes to this final form to make it feel more real:
utils.js
that returns errors.valid
state.Here it is — a fully functioning multi-step form:
Of course, there are always ways to improve anything, and this form is no exception. Here are a few ideas:
window.localStorage
so a user's form state is maintained even if they accidentally leave.We've covered a lot of topics in this guide and hope you've learned more about FormKit and how to use it to make multi-step forms easier!