Making Multi-Step Forms and Progress Indicators Accessible
AI generated
A11Y
WCAG
Accessibility · Forms · Wizard Pattern
Accessible Multi-Step Forms
fieldset per step, aria-current in the stepper, and loss free back navigation in the wizard pattern

A multi-step form is more than a sequence of individual form pages: it needs a recognizable grouping per step, a progress indicator that clearly communicates the current step, an active announcement of every step change for screen reader users, and back navigation that reliably preserves entered data. This general wizard pattern applies to registration wizards, multi-step configurators and surveys just as much as to any other multi-step form process outside of checkout.

10 min read aria-current=step General wizard pattern

1. The wizard pattern as its own, general problem

Multi-step forms show up in very different contexts: registration wizards with multiple profile steps, product configurators with steps building on each other, multi page surveys, or support forms with conditional branching. All of these cases share the same technical base structure independent of any specific checkout context, which is why the wizard pattern deserves treatment as its own, reusable accessibility topic.

Four building blocks keep recurring here and must each be implemented correctly independent of one another: the semantic grouping of fields within a step, the visual and semantic progress indicator across all steps, the active announcement of a step change, and the loss free handling of already entered data during forward and backward navigation.

2. fieldset and legend: grouping form fields per step

Every individual step of a multi-step form should be marked up as its own fieldset element, with a legend that briefly summarizes the step's purpose, for example "Step 2 of 4: Contact details". This grouping is more than pure cosmetics, it makes sure a screen reader automatically announces the context of the entire step when the first field in that step gets focused, not just the individual field label.

Without fieldset/legend, a user has to piece together the current step's context solely from individual field labels, which quickly leads to confusion, especially with similarly named fields across different steps, for example multiple address fields in different contexts.


<fieldset class="wizard-step" data-step="2">
  <legend>Step 2 of 4: Contact details</legend>

  <label for="email">Email address</label>
  <input type="email" id="email" name="email" required>

  <label for="phone">Phone number</label>
  <input type="tel" id="phone" name="phone">
</fieldset>

3. Progress stepper markup: aria-current for the active step

The progress indicator itself is usually implemented as a nav element containing an ordered list ol, where every li represents one step. The currently active step gets the attribute aria-current="step", a special value of aria-current meant exactly for this use case, distinct from the more generic values page or true, and it tells screen readers explicitly that this represents a step in a sequential process.

Already completed steps should be visually and semantically distinguishable from steps not yet reached, for example via an additional, hidden text like "completed" or "not yet reached" inside every list item. This status must never be communicated exclusively through a checkmark graphic or a color change, since both stay invisible to screen readers without accompanying text.


<nav aria-label="Progress">
  <ol class="stepper">
    <li>
      <span class="sr-only">Completed: </span>Account
    </li>
    <li aria-current="step">
      <span class="sr-only">Current step: </span>Contact details
    </li>
    <li>
      <span class="sr-only">Not yet reached: </span>Address
    </li>
    <li>
      <span class="sr-only">Not yet reached: </span>Confirmation
    </li>
  </ol>
</nav>

4. Screen reader announcement of step changes

When a user moves from one step to the next via a next button, a typical single page wizard implementation only changes the visible content in the DOM, without a full page navigation happening. Without an active announcement, a screen reader user may not notice this change at all, since focus stays at the same spot where the next button used to be.

An aria-live="polite" region that outputs a short text like "Step 3 of 4: Address" on every step change makes the transition reliably audible. This announcement does not replace the focus management from the next section, it complements it, since a live region still works even when focus cannot be set exactly right for technical reasons at a given moment.


<div id="step-announcement" class="sr-only" aria-live="polite"></div>

<script>
function goToStep(stepNumber, stepLabel, totalSteps) {
  document.getElementById('step-announcement').textContent =
    'Step ' + stepNumber + ' of ' + totalSteps + ': ' + stepLabel;
}
</script>

5. Focus management on step change: moving focus to the step heading

In addition to the live region announcement, keyboard focus should be actively moved to the heading or the legend of the new step on every step change, usually via tabindex="-1" plus .focus() in JavaScript, since non interactive elements like headings are not focusable by default. This step ensures keyboard users can continue working right at the start of the new step's content after the change, instead of having to navigate there manually.

If focus instead stays on the next button of the previous step, which no longer exists in the new step or sits somewhere else, the user loses orientation and has to tab through the entire new form content just to figure out what changed.


function focusStepHeading(stepEl) {
  const heading = stepEl.querySelector('legend, h2');
  if (heading) {
    heading.setAttribute('tabindex', '-1');
    heading.focus();
  }
}

6. Per step validation before advancing and an error summary

Clicking next should first fully validate the current step before moving to the next one. If errors occur, the step change must not happen, and focus must be moved to an error summary at the top of the current step, listing every invalid field with a jump link to the respective field.

This error summary follows the same pattern used for single step form validation, but here it applies repeatedly per step, since every step is effectively its own validation section. It matters that the error message clearly indicates which step the error is in, especially if a later summary page collects errors across all steps.

7. Interim saving and back navigation without data loss

When a user navigates back to a previous step via a back button or a clickable step in the stepper, already entered values must still be present there. Technically this can be solved either via client side state, for example an Alpine store or sessionStorage, or via server side interim saving on every step change, with the latter additionally protecting against data loss on an accidental browser reload.

From an accessibility standpoint, what matters is that back navigation never triggers a complete restart of the form, but returns exactly to the previous state including all entered values, with focus correctly set on the step heading and aria-current correctly updated in the stepper. A user who accidentally goes back a step must not be punished by having to retype data already entered.


document.addEventListener('alpine:init', () => {
  Alpine.data('wizard', () => ({
    currentStep: 1,
    values: Alpine.$persist({}).as('wizard-values'),
    goTo(step) {
      this.currentStep = step;
      this.$nextTick(() => focusStepHeading(this.$refs['step-' + step]));
    },
  }));
});

8. Clickable versus display only steps: aria-disabled for unreached steps

Whether users are allowed to jump directly to a later, not yet reached step in the stepper depends on the specific use case. If a direct jump does not make sense, because the later step depends on data from an earlier, not yet validated step, the corresponding stepper entry should be marked with aria-disabled="true", rather than simply graying it out visually without communicating that semantically.

Already completed, earlier steps, on the other hand, should almost always stay clickable, since users frequently want to jump back to correct an entry. A clickable, already completed step entry should be implemented as a button or a element, not as a plain li with a click handler and no native interactive semantics.

9. Implementation in Magento and Hyvä: a generic Alpine.js wizard component

Since the wizard pattern is not limited to checkout, it is worth building a generic Alpine.js component that encapsulates step state, per step validation, focus management and the aria-live announcement, and can be reused for a registration wizard just as well as for a product configurator or a multi-step contact form. The concrete form fields per step stay swappable, while the accessibility logic is maintained centrally in one place.

Every inline script block for focus management or step change announcement must be registered via $hyvaCsp->registerInlineScript() in the corresponding phtml template, so the Content Security Policy does not block execution.


<div x-data="wizard" x-cloak>
  <nav aria-label="Progress">
    <ol class="stepper">
      <template x-for="(step, index) in steps" :key="step.id">
        <li :aria-current="currentStep === index + 1 ? 'step' : null">
          <span x-text="step.label"></span>
        </li>
      </template>
    </ol>
  </nav>

  <div id="step-announcement" class="sr-only" aria-live="polite" x-text="announcement"></div>
</div>
Building block Technique Purpose Common mistake
Field grouping fieldset plus legend per step Automatically announcing context on focus Only individual field labels with no group context
Progress indicator aria-current=step in the stepper Clearly marking the current step Only visual highlighting with no ARIA
Step change aria-live region with a step announcement Making the change audible without a page reload Silent DOM change with no announcement
Focus after change Focus on the new step's heading Enabling users to continue right away Focus stays on a now vanished button
Back navigation State storage per step Preserving entered values Form restarts on going back

Mironsoft

WCAG audits, accessible Magento shops, and training

Not sure whether the shop is actually accessible?

We audit existing Magento shops against WCAG 2.2, fix concrete barriers in the Hyvä frontend, and train teams so accessibility stays anchored in the development process for good.

WCAG Audit

Systematically review the shop against WCAG 2.2 AA, with a prioritized issue list.

Fixing Barriers

Concrete implementation: keyboard operability, screen reader support, contrast, forms.

Team Training

Raise developer and editor awareness for accessible implementation day to day.

10. Summary

Accessible Multi-Step Forms: The Essentials

Core idea

fieldset and legend per step automatically supply context as soon as a user focuses the first field of a step.

Progress indicator

aria-current=step semantically marks the current step unambiguously, in addition to any visual highlighting.

Step change

An aria-live announcement plus deliberate focus management on the new step's heading make every change traceable.

Data preservation

Back navigation must never discard already entered values, regardless of whether storage happens client side or server side.

11. FAQ: Accessible Multi-Step Forms: The Essentials

1Why should every step of a wizard get its own fieldset?
So a screen reader automatically announces the context of the entire step via the legend when the first field gets focused.
2What does aria-current=step mean in a progress stepper?
It is a special value of aria-current that explicitly marks a step in a sequential process, distinct from the more generic values page or true.
3Is a color highlight of the current step in the stepper enough?
No, without aria-current and accompanying text the status stays invisible to screen readers, color alone is not sufficient.
4How do screen reader users learn about a step change without a page reload?
Via an aria-live=polite region that outputs a short text with the step number and label on every change.
5Where should focus be moved after a step change?
To the heading or legend of the new step, usually via tabindex=-1 and focus() in JavaScript, since headings are not focusable by default.
6What happens when validation errors occur while advancing?
The step change must not happen, focus is instead moved to an error summary with jump links to the invalid fields.
7How do I prevent data loss during back navigation?
Via client side state like an Alpine store or sessionStorage, or alternatively server side interim saving on every step change.
8Should every step in the stepper be clickable?
Already completed steps almost always, steps not yet reached only if there is no data dependency issue, otherwise mark them with aria-disabled.
9Is the wizard pattern only relevant for checkout?
No, it applies equally to registration wizards, product configurators, surveys and any other multi-step form.
10Is a live region alone enough, without additional focus management?
No, both complement each other: the live region secures the announcement, focus management enables working directly on the new step.