HTMX-Patterns 01: How to Build a Multi-Step Form in HTMX
HTMX Patterns — blueprints for the most common UI requirements.
What is a Multi-Step Form?
If you need to collect a large amount of data from a user, a very long form might not provide the best user experience. This is where Multi-Step Forms come in, offering a more user-friendly approach by dividing the long form into smaller sections, guiding the user step by step through the input process. This also provides a sense of progress rather than overwhelming the user with a large number of input fields at once.
HTMX Demo Shop: https://tractorstore.inauditech.com/account/signup
Possible Technical Solutions
While the user moves through the multiple steps, you need to store the state (step number, input data, errors) somewhere so that they can navigate between steps without losing previously entered data.
When working with HTMX, there are two options:
Backend Managed State
The backend stores the current state in a database or memory. As the user progresses through the steps, more data is stored until the form is completed.
HTML as the Engine of Application State
The state is entirely stored in the HTML. All input fields for all steps are part of the HTML at all times. As the user progresses through the steps, inputs from previous steps remain filled but are hidden from view.
Implementation of a HATEOAS Multi-Step Form
To keep the full state of the multi-step form in the HTML, all fields must be present, either visible or hidden. The general approach is to logically divide the full form into steps, displaying only the fields relevant to the current step. When the user moves to the next step, the currently visible fields are validated, and if successful, the next step’s fields are revealed. In that way, all fields of the form are gradually completed.
Frontend Implementation
In our example, we will use a three-step form to cover all use cases: the first step, an intermediate step, and the final step.
A basic example could look like this (full example on GitHub):
<form id="hx-order-form">
<input type="hidden" value="${signupForm.step}" name="step">
<style>
.step-${currentStepIndex} {
display: inherit;
}
</style>
<div id="sliding-window" class="overflow-hidden">
<fieldset id="step-customer" class="step-0 step hidden">
<h3>Customer</h3>
<label for="customer-full-name">Full Name</label>
<input type="text" id="customer-full-name" name="customer.fullName" value="${signupForm.customer.fullName}">
<label for="customer-email">Email</label>
<input type="email" id="customer-email" name="customer.email" value="${signupForm.customer.email}">
<label for="customer-password">Password</label>
<input type="text" id="customer-password" name="customer.password" value="${signupForm.customer.password}">
</fieldset>
<fieldset id="step-company" class="step-1 step hidden">
<h3>Company</h3>
<label for="company-name">Name</label>
<input type="text" id="company-name" name="company.name" value="${signupForm.company.name}">
<label for="company-vat-id">Tax Id</label>
<input type="text" id="company-vat-id" name="company.vatId" value="${signupForm.company.vatId}">
</fieldset>
<fieldset id="step-address" class="step-2 step hidden">
<h3>Address</h3>
<label for="address-street">Street</label>
<input type="text" id="address-street" name="address.street" value="${signupForm.address.street}">
<label for="address-city">City</label>
<input type="text" id="address-city" name="address.city" value="${signupForm.address.city}">
<label for="address-zip">Zip Code</label>
<input type="text" id="address-zip" name="address.zip" value="${signupForm.address.zip}">
</fieldset>
</div>
<div>
<div>
@if(!signupForm.step.isFirst())
<div id="submit-button-prev"
hx-post="/account/fragments/v1/signupform/prev"
hx-target="#hx-order-form"
hx-swap="outerHTML settle:200ms"
>
<button type="submit">Back</button>
</div>
@endif
</div>
<div id="submit-button-next"
hx-post="/account/fragments/v1/signupform/next"
hx-target="#hx-order-form"
hx-swap="outerHTML settle:200ms"
>
@if(signupForm.step.isLast())
<button type="submit">Summit</button>
@else
<button type="submit">Next</button>
@endif
</div>
</div>
</form>Let’s examine the code above.
The first field (step) in the form is used to store the current step and is referenced by the backend when the form is submitted.
Next, we have some styling. By default, all steps (fieldset) are hidden, and a CSS rule makes the current step visible.
Get Alexander Heerens’s stories in your inbox
Join Medium for free to get updates from this writer.
The form data itself is then structured, with input fields grouped into steps (fieldset). In this example, we use simple text inputs, but you can use any form elements. Error messages should also be handled within this section.
At the end, we have the previous/next navigation. Depending on the current state, the buttons are shown or hidden. Navigation always submits the complete form, allowing validation and re-rendering at the current (invalid), next, or previous step.
Adding Animations
The HTML and CSS now allow us to show and hide different form steps. Next, we want to add a sliding animation.
To achieve this effect, we create a sliding window that contains all steps (fieldset) placed next to each other, hiding any steps that are moved out of view using overflow-x-hidden.
For example, when navigating from step-1 to step-2:
- The form initially contains both
step-1andstep-2, withstep-1visible andstep-2positioned outside the viewport. - A CSS transition slides both steps to the left, making
step-1disappear under the window whilestep-2becomes visible. - Finally,
step-1is hidden, andstep-2remains visible.
HTMX has built-in mechanisms to support animations and transitions. When triggering a content swap, HTMX adds the htmx-added class to newly inserted content. In this example, this class is applied for 200ms.
Combined with the navigation direction (steppedDirection) set by the backend, we can apply CSS transitions for both forward and backward navigation.
<style>
.step-${currentStepIndex} {
display: inherit;
}
@if(steppedDirection == Direction.NEXT)
.htmx-added .step-${currentStepIndex-1} {
display: inherit;
}
@keyframes slide-to-left {
to { transform: translateX(-100%); }
}
.htmx-added .step {
animation: 200ms both running slide-to-left;
}
@endif
@if(steppedDirection == Direction.PREV)
.htmx-added .step-${currentStepIndex+1} {
display: inherit;
}
@keyframes slide-from-left {
from { transform: translateX(-100%); }
}
.htmx-added .step {
animation: 200ms both running slide-from-left;
}
@endif
</style>Backend Implementation
Since the backend in this example is not tied to a specific programming language, we will describe the functionality of the endpoints as a prompt so that you can generate the appropriate implementation for your backend (Kotlin example on GitHub).
Create a controller and signup form in KOTLIN using SPRING BOOT for two endpoints and implement the logic as described below.
Use a server-side form validation library of your choice.
Both endpoints will accept only POST and process html form data of this shape:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"customer": {
"type": "object",
"required": ["fullName", "email", "password"],
"properties": {
"fullName": { "type": "string", "minLength": 1 },
"email": { "type": "string", "format": "email" },
"password": { "type": "string", "minLength": 1 }
}
},
"company": {
"type": "object",
"required": ["name", "vatId"],
"properties": {
"name": { "type": "string", "minLength": 1 },
"vatId": { "type": "string", "minLength": 1 }
}
},
"address": {
"type": "object",
"required": ["street", "city", "zip"],
"properties": {
"street": { "type": "string", "minLength": 1 },
"city": { "type": "string", "minLength": 1 },
"zip": { "type": "string", "minLength": 1 }
}
},
"step": {
"type": "string",
"enum": ["CUSTOMER", "COMPANY", "ADDRESS"]
}
},
"required": ["customer", "company", "address", "step"]
}
The template for the form is already provided and named "signup/MultiStepSignup"
Endpoint: "/account/fragments/v1/signupform/next"
- validate the form data
- if the complete form data is valid AND the step is the last step:
- redirect to page "/account/signup/thanks"
- check if there are errors for the current step. So if the step is "CUSTOMER" check for errors in the "customer" object, if the step is "COMPANY" check for errors in the "company" object, etc.
- if there are errors:
- set the 'steppedDirection' to null
- show the form again and the errors for the current step
- if there are no errors:
- set the 'steppedDirection' to "NEXT"
- show the form for the next step
Endpoint: "/account/fragments/v1/signupform/next"
- do not validate the form data
- set the 'steppedDirection' to "PREV"
- show the form for the previous stepI tested the prompt for KOTLIN using SPRING BOOTand PYTHON using FASTAPI, and both results worked well with ChatGPT-4o.
A handcrafted Kotlin example can be found here: Controller and HTML view








