Validating a Multi-Step Form One Step at a Time

A multi-step form must confirm the current step is valid before it advances, so this page shows how to gate each transition through a single Constraint Validation API call scoped to the fields on the active step, rather than validating the entire form on final submit.

When to Use This Per-Step Gating Approach

Reach for per-step validation when a user cannot see or reach later fields yet, and forcing them to fix a problem on step three before they finish step one would be confusing. The alternative — validating everything only at the end — is covered under Real-Time vs On-Submit Feedback Timing and suits short single-screen forms.

Choose this pattern when:

  • Each step maps to a distinct <fieldset> or panel and only one is visible at a time.
  • Advancing should be blocked until the visible fields pass, but hidden steps must not report errors prematurely.
  • You want native error bubbles and focus behaviour without pulling in a form library.
  • Conditional branches change which step comes next — see Conditional Field Validation on Selection.
Per-Step Validation Gate A user submits the active step, the step's fields are checked, and only a valid step unlocks the next panel. Each Next click gates on the active step only Click Next on the visible step Check step fields reportValidity() on the set Reveal next step only if the set is valid
The Next control never advances until every control in the current step passes its own constraints.

Gating Steps with checkValidity() and reportValidity()

The baseline ships one <form novalidate> so the browser never blocks submission on its own, and each step is a <fieldset> you validate on demand. Because reportValidity() exists on individual elements as well as the form, you can scope the check to the active step’s controls.

// One form, novalidate, with each step as a <fieldset data-step>.
const form = document.querySelector<HTMLFormElement>("#wizard")!;
const steps = Array.from(form.querySelectorAll<HTMLFieldSetElement>("[data-step]"));
let active = 0;

// Show only the active step; disable hidden controls so they never block
// submission or land in the tab order while off-screen.
function render(): void {
  steps.forEach((step, i) => {
    const isActive = i === active;
    step.hidden = !isActive;
    step.disabled = !isActive;
  });
}

// Validate just the visible step. reportValidity() paints native bubbles
// and moves focus to the first invalid control for us.
function currentStepIsValid(): boolean {
  const controls = steps[active].querySelectorAll<HTMLInputElement | HTMLSelectElement>(
    "input, select, textarea",
  );
  for (const control of controls) {
    if (!control.checkValidity()) {
      control.reportValidity(); // shows the bubble on the first offender
      return false;
    }
  }
  return true;
}

form.querySelector("#next")!.addEventListener("click", () => {
  if (!currentStepIsValid()) return; // gate: stay on the step
  active = Math.min(active + 1, steps.length - 1);
  render();
});

form.querySelector("#back")!.addEventListener("click", () => {
  active = Math.max(active - 1, 0);
  render(); // never validate on Back — let users retreat freely
});

// Final submit re-checks the whole form; disabled steps are skipped natively.
form.addEventListener("submit", (event) => {
  if (!form.reportValidity()) event.preventDefault();
});

render();

The key move is setting disabled on hidden fieldsets. A disabled control is excluded from constraint validation and from form submission, so a required field three steps away can never fail the current gate or the final form.reportValidity().

Step Controller: Configuration Reference

These are the values you tune per wizard. Keep them on the controller or as data attributes so the same script drives any number of steps.

Option Type Default Purpose
active number 0 Index of the currently visible step.
data-step attribute Marks a <fieldset> as one step in the sequence.
hidden boolean true for inactive Removes off-screen steps from layout and the a11y tree.
disabled boolean true for inactive Excludes hidden controls from validation and submission.
validateOnBack boolean false Whether retreating re-checks the step being left.
focusFirstInvalid boolean true Whether reportValidity() is allowed to move focus.

Verification Steps

import { test, expect } from "@playwright/test";

test("Next is gated on the active step", async ({ page }) => {
  await page.goto("/wizard");
  await page.getByRole("button", { name: "Next" }).click();
  // Still on step one because email is empty.
  await expect(page.getByLabel("Email")).toBeVisible();

  await page.getByLabel("Email").fill("dev@example.com");
  await page.getByRole("button", { name: "Next" }).click();
  await expect(page.getByLabel("Shipping address")).toBeVisible();
});

Edge Cases & Failure Modes

Hidden required fields blocking the final submit. If you hide steps with CSS alone, their required controls still participate in form.reportValidity(), and the browser tries to focus an invisible field it cannot scroll to. Setting disabled on the inactive <fieldset> removes those controls from validation entirely; re-enable on render().

Async checks racing the Next click. A step that depends on asynchronous server checks — a username-availability probe, say — can resolve after the user has already advanced. Await the pending promise inside the gate and set a custom validity message with setCustomValidity() before calling reportValidity(), so the native bubble reflects the server result.

Focus lost after a blocked advance. When the gate fails but the first invalid control is scrolled out of view, users may not notice the bubble. Delegate to your standard focus management after a failed submit so the offending field is scrolled into view and announced, exactly as it would be on a single-page form.

Announcing Step Transitions to Assistive Technology

A visual wizard makes the step change obvious, but a screen-reader user gets nothing from a hidden toggle unless you tell them what happened. The render() function above swaps which panel is visible without moving focus or emitting any semantic signal, so a non-sighted user clicks Next, hears silence, and has no idea a new set of fields appeared.

Two mechanisms close that gap, and they are complementary rather than interchangeable. Move focus to the heading of the newly revealed step so a keyboard user lands inside it, and announce the step change through a polite live region so the transition is narrated without stealing focus mid-action. The heading needs tabindex="-1" to be programmatically focusable; the live region is a visually hidden element you update on every transition.

// A visually hidden <p aria-live="polite" id="wizard-status"> sits outside the
// steps so its updates are not removed from the a11y tree when a step hides.
const status = document.querySelector<HTMLElement>("#wizard-status")!;

function render(): void {
  steps.forEach((step, i) => {
    const isActive = i === active;
    step.hidden = !isActive;
    step.disabled = !isActive;
  });

  const heading = steps[active].querySelector<HTMLElement>("[data-step-heading]");
  // Focus the heading, not the first input: announcing the step title before
  // the field gives the user context for what they are about to fill in.
  heading?.focus();

  // Narrate progress without hijacking focus. Screen readers queue polite
  // updates, so this is spoken after the heading's accessible name.
  status.textContent = `Step ${active + 1} of ${steps.length}`;
}

Pair the live region with a role="progressbar" element carrying aria-valuenow, aria-valuemin, and aria-valuemax if your wizard shows a progress meter, so the same numeric state that drives the visual bar is exposed to the accessibility tree. Keep the announcement terse — a full re-read of every field label on each transition is noise, not help. The deeper focus mechanics, including why preventScroll sometimes matters here, are covered in managing focus after validation failure.

Persisting Step Progress Across Reloads

Long wizards lose data on an accidental refresh unless you persist as you go, and a per-step form gives you a natural checkpoint: every successful gate is a moment where the current step’s values are known-valid and worth saving. Serialise the form on each forward transition and restore on load, so a dropped connection or a mis-tapped back-button never costs the user their progress.

const STORAGE_KEY = "wizard-draft";

// Snapshot every named control into a plain object. FormData handles inputs,
// selects, and textareas uniformly; disabled hidden steps are simply absent.
function saveDraft(): void {
  const data = Object.fromEntries(new FormData(form).entries());
  sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ active, data }));
}

// Restore on load, then re-run render() so the correct step shows. Guard the
// parse: a malformed or stale draft must not throw and strand the user.
function restoreDraft(): void {
  const raw = sessionStorage.getItem(STORAGE_KEY);
  if (!raw) return;
  try {
    const { active: savedStep, data } = JSON.parse(raw) as {
      active: number;
      data: Record<string, string>;
    };
    for (const [name, value] of Object.entries(data)) {
      const field = form.elements.namedItem(name);
      if (field instanceof HTMLInputElement || field instanceof HTMLSelectElement) {
        field.value = value;
      }
    }
    active = Math.min(savedStep, steps.length - 1);
  } catch {
    sessionStorage.removeItem(STORAGE_KEY); // discard corrupt drafts silently
  }
}

Call saveDraft() inside the Next handler after the gate passes, and restoreDraft() before the first render(). Prefer sessionStorage over localStorage for anything remotely sensitive — it clears when the tab closes, which is the right lifetime for a half-finished checkout or application and avoids leaving personal data on a shared machine. Clear the key explicitly on successful final submit so a completed wizard does not silently repopulate the next time the user starts over. If a step depends on server-derived state, treat the restored values as untrusted and re-run the same gate against them before advancing, exactly as you would for fresh input.

Frequently Asked Questions

Should I re-validate previous steps when the user clicks Back?

No. Blocking a user from retreating is hostile and traps them behind an error they may want to revisit later. Validate only on forward transitions and on final submit. Leave validateOnBack off unless a legal flow demands it.

How do I validate a field on one step against a field on another?

Because inputs stay in the DOM across steps, you can read any earlier value when gating the current step. This is standard cross-field validation: compute the relationship, then call setCustomValidity() on the dependent control before reportValidity().

Can I use a schema instead of native constraints per step?

Yes. Slice your schema per step and run only the relevant fields through Zod schema validation in the gate, mapping each issue back onto its control via setCustomValidity(). The native bubble and focus handling then work identically to the constraint-only version.

← Back to Progressive Disclosure Techniques