Formclick

How to Create a Multi-Step HTML Form

Split long forms into friendly steps with a progress bar, per-step validation and back/next buttons — using HTML, CSS and a few lines of vanilla JavaScript.

FFormclick Team4 min read
White floating steps on vibrant orange wall create a minimalist and conceptual design.
Photo by Kanhaiya Sharma on Pexels
On this page
  1. When to use a multi-step form
  2. How it works
  3. The HTML
  4. The JavaScript
  5. The CSS
  6. Best practices for multi-step forms
  7. Submitting the form

Long forms are intimidating. Show someone twenty fields at once and many will leave before they start. Split the same fields into three or four short steps and completion rates usually go up — each step feels quick, and a progress bar shows the finish line.

In this tutorial you'll build a multi-step form with HTML, CSS and about 40 lines of plain JavaScript. It submits as one normal form at the end.

When to use a multi-step form

  • Use it for applications, quotes, onboarding, bookings and anything with more than about 8 fields or clearly separate topics.
  • Don't use it for short forms — a contact form split into three steps is just more clicking.

How it works

All the steps live inside a single <form>. Each step is a <fieldset>. JavaScript shows one at a time and validates the current step before moving on. Hidden steps are still part of the form, so on the last step everything is submitted together.

The HTML

<form id="quote" class="msf" action="https://api.formclick.io/submit" method="POST" novalidate>
  <input type="hidden" name="access_key" value="YOUR_ACCESS_KEY">
  <input type="hidden" name="subject" value="New quote request">
  <input type="checkbox" name="botcheck" class="hp" tabindex="-1" autocomplete="off">

  <div class="progress" aria-hidden="true"><span></span></div>
  <p class="step-count" aria-live="polite">Step 1 of 3</p>

  <fieldset class="step">
    <legend>About you</legend>
    <label for="name">Full name</label>
    <input id="name" name="name" autocomplete="name" required>
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required>
  </fieldset>

  <fieldset class="step" hidden>
    <legend>Your project</legend>
    <label for="type">Project type</label>
    <select id="type" name="project_type" required>
      <option value="">Choose…</option>
      <option>New website</option>
      <option>Redesign</option>
      <option>Online store</option>
    </select>
    <label for="budget">Budget</label>
    <select id="budget" name="budget" required>
      <option value="">Choose…</option>
      <option>Under $2,000</option>
      <option>$2,000–$10,000</option>
      <option>Over $10,000</option>
    </select>
  </fieldset>

  <fieldset class="step" hidden>
    <legend>Details</legend>
    <label for="deadline">Ideal launch date</label>
    <input id="deadline" name="deadline" type="date">
    <label for="details">Tell us about the project</label>
    <textarea id="details" name="details" rows="5" required minlength="20"></textarea>
  </fieldset>

  <div class="nav">
    <button type="button" class="back" hidden>Back</button>
    <button type="button" class="next">Next</button>
    <button type="submit" class="submit" hidden>Send request</button>
  </div>
</form>

Key points:

  • novalidate turns off the browser's automatic validation on submit, because we'll trigger it ourselves one step at a time.
  • The hidden attribute hides inactive steps — and if JavaScript fails to load, you can remove it with CSS so the form still works as one long page.
  • The step counter uses aria-live so screen readers announce each step change.

The JavaScript

const form = document.getElementById('quote');
const steps = Array.from(form.querySelectorAll('.step'));
const back = form.querySelector('.back');
const next = form.querySelector('.next');
const submit = form.querySelector('.submit');
const bar = form.querySelector('.progress span');
const count = form.querySelector('.step-count');
let current = 0;

function show(index) {
  steps.forEach(function (step, i) { step.hidden = i !== index; });
  back.hidden = index === 0;
  next.hidden = index === steps.length - 1;
  submit.hidden = index !== steps.length - 1;
  bar.style.width = ((index + 1) / steps.length) * 100 + '%';
  count.textContent = 'Step ' + (index + 1) + ' of ' + steps.length;
  const first = steps[index].querySelector('input, select, textarea');
  if (first) first.focus();
  current = index;
}

// Validate only the fields in the current step.
function stepIsValid() {
  const fields = steps[current].querySelectorAll('input, select, textarea');
  for (const field of fields) {
    if (!field.checkValidity()) {
      field.reportValidity();
      return false;
    }
  }
  return true;
}

next.addEventListener('click', function () {
  if (stepIsValid()) show(current + 1);
});
back.addEventListener('click', function () { show(current - 1); });

form.addEventListener('submit', function (event) {
  if (!stepIsValid()) event.preventDefault();
});

// Pressing Enter on an early step goes to the next step instead of submitting.
form.addEventListener('keydown', function (event) {
  if (event.key === 'Enter' && event.target.tagName !== 'TEXTAREA' && current < steps.length - 1) {
    event.preventDefault();
    next.click();
  }
});

show(0);

checkValidity() and reportValidity() reuse the browser's built-in validation — required, type="email", minlength — so you don't write any validation rules yourself.

The CSS

.msf {
  max-width: 520px;
  margin: 40px auto;
  padding: 32px;
  background: #fff;
  border: 1px solid #e6e6f0;
  border-radius: 20px;
  font-family: system-ui, sans-serif;
}
.progress {
  height: 6px;
  background: #ececf6;
  border-radius: 999px;
  overflow: hidden;
}
.progress span {
  display: block;
  height: 100%;
  width: 0;
  background: linear-gradient(90deg, #645cec, #818cf8);
  transition: width .3s ease;
}
.step-count { margin: 10px 0 20px; font-size: 13px; color: #64748b; }

.step { border: 0; padding: 0; margin: 0; }
.step legend { font-size: 22px; font-weight: 800; margin-bottom: 16px; }
.step label { display: block; font-weight: 600; margin: 14px 0 6px; }
.step input, .step select, .step textarea {
  width: 100%;
  padding: 12px 14px;
  font: inherit;
  border: 1px solid #d4d4e0;
  border-radius: 10px;
}

.nav { display: flex; gap: 10px; margin-top: 26px; }
.nav button {
  flex: 1;
  padding: 14px;
  font: inherit;
  font-weight: 700;
  border-radius: 10px;
  cursor: pointer;
}
.next, .submit { color: #fff; background: #645cec; border: 0; }
.back { color: #334155; background: #fff; border: 1px solid #d4d4e0; }
[hidden] { display: none !important; }
.hp { display: none; }

Best practices for multi-step forms

  • Start with the easiest step. Once people have invested a few seconds, they're more likely to finish.
  • Group by topic, not by field count — "About you", "Your project", "Details".
  • Always show progress: "Step 2 of 3" plus a bar.
  • Never lose data when someone goes back — because every step is in the same form, values are kept automatically.
  • Keep it to 3–5 steps. More than that and the progress bar starts to feel like a threat.
  • Add a review step for high-stakes forms (applications, orders) so people can check everything before sending.

Submitting the form

On the final step, the submit button sends every field from every step in one request to Formclick. You receive a single, complete submission in your inbox and dashboard. Add <script src="https://cdn.formclick.io/formclick.min.js"></script> if you'd like the success message to appear in place without leaving the page.

Want to keep things simpler? Read How to Create an HTML Form Without JavaScript. For more layout ideas, see HTML Forms for Beginners: Practical Examples.

Found this useful? Share it.

All articles