How to Build a Multi-Step Form in HTML (No Library Needed)
A multi-step form is one HTML form shown a step at a time. The 30-line script, the Enter-key bug most tutorials ship, a no-JavaScript fallback, and a free generator.
The ShipMyForm team
· 5 min read
A multi-step form is one ordinary HTML form whose fields are shown a group at a time. You do not need a wizard library or a request per step: wrap each group in a container, hide all but the current one, validate the visible fields when the visitor clicks Next, and reveal Submit on the last step. That is about 30 lines of JavaScript, and everything still arrives as a single submission. If you would rather not write it, our free multi-step form generator produces the same code for HTML, React, Vue, Svelte, and Astro.
Full disclosure: the generator is ours, and so is the form backend the examples post to. The technique itself is plain HTML and JavaScript and works with any backend, or your own server.
When a multi-step form is worth it
Steps help when a form is long enough to look like work. A quote request with twelve fields reads as a chore on one page; the same fields as "About you", "Your project", "Details" open with two easy questions. Steps also give you natural places for headings and help text.
They hurt when the form is short. A three-field contact form split into steps just adds clicks. There is no universal conversion number here, whatever a landing page tells you: results depend on the form and the audience, so treat it as something to test. A reasonable rule of thumb is to consider steps from about seven fields up, or whenever the fields fall into obviously different topics.
The markup: still one form
Each step is a container inside the same <form>. The navigation buttons are
ordinary type="button" elements, and there is exactly one real submit button.
<form action="https://shipmyform.com/f/YOUR_FORM_ID" method="POST" id="contact-form">
<p data-smf-progress aria-live="polite" hidden>Step 1 of 2</p>
<div data-smf-step>
<h3>About you</h3>
<label>
<span>Name *</span>
<input type="text" name="name" required />
</label>
<label>
<span>Email *</span>
<input type="email" name="email" required />
</label>
</div>
<div data-smf-step>
<h3>Your message</h3>
<label>
<span>Message *</span>
<textarea name="message" required></textarea>
</label>
</div>
<div>
<button type="button" data-smf-back hidden>Back</button>
<button type="button" data-smf-next hidden>Next</button>
<button type="submit" data-smf-submit>Send message</button>
</div>
</form>Notice what is hidden in the markup: Back, Next, and the progress line. The steps themselves and the Submit button are visible. That ordering is the no-JavaScript fallback, covered below.
The script
(() => {
const form = document.getElementById("contact-form");
const steps = [...form.querySelectorAll("[data-smf-step]")];
const back = form.querySelector("[data-smf-back]");
const next = form.querySelector("[data-smf-next]");
const submit = form.querySelector("[data-smf-submit]");
const progress = form.querySelector("[data-smf-progress]");
const last = steps.length - 1;
let current = 0;
function show(i) {
current = i;
steps.forEach((el, n) => (el.hidden = n !== i));
back.hidden = i === 0;
next.hidden = i === last;
submit.hidden = i !== last;
progress.hidden = false;
progress.textContent = "Step " + (i + 1) + " of " + steps.length;
}
form.noValidate = true;
function valid() {
return [...steps[current].querySelectorAll("input, select, textarea")].every(
(el) => el.reportValidity(),
);
}
next.addEventListener("click", () => valid() && show(current + 1));
back.addEventListener("click", () => show(current - 1));
form.addEventListener("submit", (e) => {
if (current === last && valid()) return;
e.preventDefault();
e.stopImmediatePropagation();
if (current < last && valid()) show(current + 1);
});
show(0);
})();reportValidity() does the heavy lifting: it runs the browser's own checks
(required, type="email", pattern, min, max) on one field and shows
the native error bubble. every stops at the first failure, so the visitor
sees one problem at a time, on the step they are looking at.
The Enter-key trap most tutorials ship
Here is the bug we hit while building the generator, and that a lot of copy-paste wizard code still has. Fill in step one, press Enter, and nothing happens. No error, no navigation, nothing in the console worth reading.
The cause: pressing Enter triggers the form's implicit submission, and before
the browser fires the submit event it validates the entire form. The
required message field on step two is empty, so validation fails. The browser
then tries to focus the invalid field to show its error, cannot, because the
field is hidden, and gives up. Your submit handler never runs.
Two lines fix it, and both are in the script above:
form.noValidate = trueturns off whole-form validation, so thesubmitevent always reaches your handler.- The handler validates the visible step itself. On an early step it moves forward instead of submitting; on the last step it lets the real submit through only if that step is valid.
Set noValidate from the script, not as an attribute in the HTML. That way
visitors without JavaScript keep the browser's normal validation.
hidden only works until your stylesheet sets display.
A step with display: flex (or a Tailwind flex class)
stays visible when hidden. Space the fields inside a step with margins, or add
[hidden] { display: none !important }.
What happens without JavaScript
Because Back, Next, and the progress line start hidden and the steps start
visible, a visitor with scripts blocked sees one long form with a single Submit
button and native validation. It is not pretty, and it works. The script then
upgrades the page by hiding steps two onward. This is cheaper than it sounds:
it is only a question of which elements carry hidden in the HTML.
The same idea in React, Vue, and Svelte
In a component framework you do not need the data attributes or the query selectors. One state variable holds the current step and each wrapper is hidden unless it matches. The validation and Enter-key logic is identical. The React version:
const [step, setStep] = useState(0);
const stepEls = useRef([]);
function validStep() {
return [...stepEls.current[step].querySelectorAll("input, select, textarea")].every(
(el) => el.reportValidity(),
);
}
function next() {
if (validStep()) setStep((s) => s + 1);
}
function onSubmit(e) {
if (step < LAST || !validStep()) {
e.preventDefault();
if (step < LAST) next();
}
}
// <form noValidate onSubmit={onSubmit}>
// <div hidden={step !== 0} ref={(el) => { stepEls.current[0] = el; }}>…</div>Keep every step mounted and toggle hidden rather than rendering only the
current step. Unmounting a step throws away what the visitor typed, and its
fields drop out of the FormData you submit. Vue does the same with v-show
(not v-if), Svelte with hidden={step !== 0}.
Three ways to get there
| Write it by hand | Wizard library or plugin | Generator | |
|---|---|---|---|
| Dependencies | None | One package, sometimes jQuery | None |
| Works without JavaScript | If you plan for it | Rarely | Yes (HTML, Astro output) |
| Enter key handled | If you know the trap | Varies | Yes |
| Framework versions | You port it | One framework each | HTML, React, Vue, Svelte, Astro |
| Animated transitions, branching logic | Whatever you build | Often included | Not included |
| Time | An hour or two | Setup plus learning the API | A couple of minutes |
Libraries earn their place when you need animated transitions, conditional branching between steps, or saved drafts. For a linear form, they are a dependency wrapped around the 30 lines above.
Using the generator
- Open the multi-step form generator.
- Click Add step where you want a break, and give each step a title if you want headings. Drag fields between steps.
- Page through the steps with the arrows above the live preview. The preview is sandboxed and runs no scripts, so the arrows stand in for Back and Next.
- Pick plain CSS, Tailwind, or unstyled output, choose your framework tab, and copy the code.
- Put a form endpoint in the
action. With ShipMyForm, that is a free form ID: submissions are stored, spam-filtered, and emailed to you, 100 a month with no card.
Have a long Google Form already? The Google Forms to HTML converter imports its sections as steps.
Accessibility notes
- The progress line uses
aria-live="polite", so screen readers announce "Step 2 of 3" when it changes. - Hidden steps are removed from the accessibility tree and the tab order by
the
hiddenattribute, which is what you want. - Give steps real headings, and keep labels attached to their inputs. Native
validation bubbles are read out by assistive technology; custom error
markup needs
aria-describedbywiring that the native route gives you free. - Nothing here moves focus on step change. If your steps are tall, consider focusing the new step's heading so keyboard users land in the right place.
Next steps
- Add server-side rules on top of the browser checks: form validation.
- Building a long lead form? See the request-a-quote form guide.
- Submitting without a page reload: submit a form with fetch.
- Start free: 100 submissions a month, no credit card.
Frequently asked questions
- How do you make a multi-step form in HTML?
- Write one normal form and wrap each group of fields in a container. A short script hides every container except the current one, checks the visible fields when the visitor clicks Next, and shows the Submit button on the last step. Because it is still a single form, everything is sent in one ordinary POST request.
- Do I need a library or plugin for a multi-step form?
- No. About 30 lines of plain JavaScript cover step switching, per-step validation, a progress indicator, and Enter-key handling. In React, Vue, or Svelte a single state variable for the current step does the same job with no extra packages.
- Why does pressing Enter do nothing in my multi-step form?
- Browsers validate the entire form before they fire the submit event, including required fields on steps that are still hidden. If any hidden field is invalid, the submit is cancelled silently because the browser cannot focus a hidden control. The fix is to set novalidate on the form and validate only the visible step yourself with reportValidity().
- Should each step of a multi-step form be submitted separately?
- Usually not. Keeping it one form means one request, one stored submission, and no partial records to reconcile. Submit per step only when you genuinely need to save partial progress, which requires your own server-side session handling.
- Do multi-step forms convert better than single-page forms?
- It depends on the form. Splitting helps when a form is long enough to look intimidating, because the first step feels small. For a three-field contact form, extra clicks only add friction. Treat it as something to test on your own traffic rather than a rule.
- Is there a free multi-step form generator?
- Yes. ShipMyForm's multi-step form generator lets you add step breaks, name each step, and copy the result as HTML, React, Vue, Svelte, or Astro code, with Back and Next buttons, a progress indicator, and per-step validation. It is free and needs no signup.
Related guides
How to Add Validation to Your Form
Reject bad submissions with server-side rules — required fields, formats, and limits — in a few clicks, no backend code. fetch() callers get per-field errors.
Request a Quote Form: Examples & Best Practices (2026)
What to put on a request-a-quote form, real examples by industry, and the best practices that turn more visitors into qualified quote requests.
How to Submit a Form with JavaScript fetch()
The vanilla-JS pattern for submitting a form without a page reload — intercept submit, fetch() the data, and render success and per-field errors from the JSON response.