Accessibility pattern · Forms and flows

Multi-step form

Each step is a fieldset whose heading takes focus when the step changes, so a screen reader announces where the person is, and the progress list marks the current step with aria-current="step" and finished ones in words. Every step is checked before moving on, going back keeps every answer, and a review step links back to each part with a Change button.

WCAG criteria
8
Keyboard rules
5
Checked with
axe, keyboard and the inspector

Live demo

Try it

Use it with a mouse, a keyboard or a screen reader. The inspector beside it shows what the browser tells assistive technology as you go: focus, state changes and announcements.

Checkout

Block-print cotton bedcover, double

  1. Details
  2. Delivery
  3. Payment
  4. Review

Step 1 of 4 Your details

For the receipt.

10 digits, for delivery updates.

The demo works without JavaScript only as far as its HTML does; the inspector needs JavaScript.

Keyboard

Keys it answers to

Every action works without a pointer.

KeyWhat it does
Tab or ShiftTabMoves through the fields of the step on screen, then Back and Continue. Other steps are hidden, so Tab never reaches them.
EnterIn a field, does what Continue does: checks this step, then moves on or lists the problems.
Enter or SpaceOn Back, shows the previous step with every answer kept, and moves focus to its heading.
Enter or SpaceOn Change in the review, opens that step; Continue there brings you back to the review.
Arrow Down or Arrow UpIn a group of choices, picks the next or previous option.

Screen readers

What it announces

Written from the roles, names and states in the markup.

WhenExpected announcement
Continue moves to the second stepStep 2 of 4 Delivery, heading level 4
The steps list is readCheckout steps, list, 4 items. Details, Done. Delivery, current step. Payment. Review.
Continue is pressed with the mobile number missingThere is a problem with your details, group. Enter your mobile number
Back is pressedStep 1 of 4 Your details, heading level 4
Change is pressed on the delivery part of the reviewStep 2 of 4 Delivery, heading level 4
The order is placedOrder placed. This demo sends nothing.

These are expected announcements, not recordings. Wording and order differ between screen readers and browsers.

Code

Copy the code

The exact files this demo runs on. The styles are served with a prefix that keeps this site's own styles out of the demo; what you copy is the original.

The markup

<form class="ap-wizard" data-ap-wizard novalidate aria-labelledby="wiz-form-name">
  <div class="ap-wizard__top">
    <h3 class="ap-wizard__name" id="wiz-form-name">Checkout</h3>
    <p class="ap-wizard__sub">Block-print cotton bedcover, double</p>
  </div>

  <ol class="ap-wizard__steps" aria-label="Checkout steps">
    <li class="ap-wizard__step" aria-current="step" data-ap-step="1">
      <span class="ap-wizard__dot" aria-hidden="true"><span class="ap-wizard__num">1</span><svg class="ap-wizard__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg></span>
      <span class="ap-wizard__step-name">Details</span>
      <span class="ap-wizard__state" hidden>Done</span>
    </li>
    <li class="ap-wizard__step" data-ap-step="2">
      <span class="ap-wizard__dot" aria-hidden="true"><span class="ap-wizard__num">2</span><svg class="ap-wizard__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg></span>
      <span class="ap-wizard__step-name">Delivery</span>
      <span class="ap-wizard__state" hidden>Done</span>
    </li>
    <li class="ap-wizard__step" data-ap-step="3">
      <span class="ap-wizard__dot" aria-hidden="true"><span class="ap-wizard__num">3</span><svg class="ap-wizard__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg></span>
      <span class="ap-wizard__step-name">Payment</span>
      <span class="ap-wizard__state" hidden>Done</span>
    </li>
    <li class="ap-wizard__step" data-ap-step="4">
      <span class="ap-wizard__dot" aria-hidden="true"><span class="ap-wizard__num">4</span><svg class="ap-wizard__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg></span>
      <span class="ap-wizard__step-name">Review</span>
      <span class="ap-wizard__state" hidden>Done</span>
    </li>
  </ol>

  <div class="ap-wizard__body">
    <div class="ap-wizard__summary" id="wiz-summary" role="group" aria-labelledby="wiz-summary-name" aria-describedby="wiz-summary-list" tabindex="-1" hidden>
      <h4 class="ap-wizard__summary-name" id="wiz-summary-name"><svg class="ap-wizard__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><path d="M12 16.5h.01"/></svg>There is a problem with your details</h4>
      <ul class="ap-wizard__summary-list" id="wiz-summary-list"></ul>
    </div>

    <fieldset class="ap-wizard__panel" data-ap-panel="1">
      <legend class="ap-wizard__legend"><h4 class="ap-wizard__h" id="wiz-h-1" tabindex="-1"><span class="ap-wizard__kicker">Step 1 of 4</span> Your details</h4></legend>
      <div class="ap-wizard__fields">
        <div class="ap-field ap-wizard__field ap-wizard__field--wide">
          <label class="ap-label" for="wiz-name">Full name</label>
          <input class="ap-input" id="wiz-name" name="name" type="text" autocomplete="name" spellcheck="false" required />
        <p class="ap-error ap-wizard__error" id="wiz-name-error" hidden><svg class="ap-wizard__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><path d="M12 16.5h.01"/></svg><span class="ap-wizard__vh">Error:</span> <span data-ap-text></span></p>
        </div>
        <div class="ap-field ap-wizard__field">
          <label class="ap-label" for="wiz-email">Email address</label>
          <p class="ap-hint" id="wiz-email-hint">For the receipt.</p>
          <input class="ap-input" id="wiz-email" name="email" type="email" autocomplete="email" spellcheck="false" required aria-describedby="wiz-email-hint" />
        <p class="ap-error ap-wizard__error" id="wiz-email-error" hidden><svg class="ap-wizard__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><path d="M12 16.5h.01"/></svg><span class="ap-wizard__vh">Error:</span> <span data-ap-text></span></p>
        </div>
        <div class="ap-field ap-wizard__field">
          <label class="ap-label" for="wiz-tel">Mobile number</label>
          <p class="ap-hint" id="wiz-tel-hint">10 digits, for delivery updates.</p>
          <input class="ap-input" id="wiz-tel" name="tel" type="tel" autocomplete="tel" required aria-describedby="wiz-tel-hint" />
        <p class="ap-error ap-wizard__error" id="wiz-tel-error" hidden><svg class="ap-wizard__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><path d="M12 16.5h.01"/></svg><span class="ap-wizard__vh">Error:</span> <span data-ap-text></span></p>
        </div>
      </div>
    </fieldset>

    <fieldset class="ap-wizard__panel" data-ap-panel="2" hidden>
      <legend class="ap-wizard__legend"><h4 class="ap-wizard__h" id="wiz-h-2" tabindex="-1"><span class="ap-wizard__kicker">Step 2 of 4</span> Delivery</h4></legend>
      <div class="ap-wizard__fields">
        <div class="ap-field ap-wizard__field ap-wizard__field--wide">
          <label class="ap-label" for="wiz-line1">Flat, house and street</label>
          <input class="ap-input" id="wiz-line1" name="line1" type="text" autocomplete="address-line1" required />
        <p class="ap-error ap-wizard__error" id="wiz-line1-error" hidden><svg class="ap-wizard__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><path d="M12 16.5h.01"/></svg><span class="ap-wizard__vh">Error:</span> <span data-ap-text></span></p>
        </div>
        <div class="ap-field ap-wizard__field">
          <label class="ap-label" for="wiz-city">Town or city</label>
          <input class="ap-input" id="wiz-city" name="city" type="text" autocomplete="address-level2" required />
        <p class="ap-error ap-wizard__error" id="wiz-city-error" hidden><svg class="ap-wizard__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><path d="M12 16.5h.01"/></svg><span class="ap-wizard__vh">Error:</span> <span data-ap-text></span></p>
        </div>
        <div class="ap-field ap-wizard__field">
          <label class="ap-label" for="wiz-pin">PIN code</label>
          <p class="ap-hint" id="wiz-pin-hint">6 digits.</p>
          <input class="ap-input ap-wizard__short" id="wiz-pin" name="pin" type="text" inputmode="numeric" autocomplete="postal-code" required aria-describedby="wiz-pin-hint" />
        <p class="ap-error ap-wizard__error" id="wiz-pin-error" hidden><svg class="ap-wizard__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><path d="M12 16.5h.01"/></svg><span class="ap-wizard__vh">Error:</span> <span data-ap-text></span></p>
        </div>
        <fieldset class="ap-wizard__choice ap-wizard__field--wide">
          <legend class="ap-label">Delivery speed</legend>
          <label class="ap-wizard__option" for="wiz-speed-standard">
            <input type="radio" id="wiz-speed-standard" name="speed" value="standard" checked />
            <span class="ap-wizard__option-text"><span class="ap-wizard__option-name">Standard, free</span> <span class="ap-wizard__option-note">3 to 5 working days</span></span>
          </label>
          <label class="ap-wizard__option" for="wiz-speed-express">
            <input type="radio" id="wiz-speed-express" name="speed" value="express" />
            <span class="ap-wizard__option-text"><span class="ap-wizard__option-name">Express, ₹149</span> <span class="ap-wizard__option-note">Next working day</span></span>
          </label>
        </fieldset>
      </div>
    </fieldset>

    <fieldset class="ap-wizard__panel" data-ap-panel="3" hidden>
      <legend class="ap-wizard__legend"><h4 class="ap-wizard__h" id="wiz-h-3" tabindex="-1"><span class="ap-wizard__kicker">Step 3 of 4</span> Payment</h4></legend>
      <fieldset class="ap-wizard__choice" id="wiz-pay" aria-describedby="wiz-pay-hint">
        <legend class="ap-label">How would you like to pay?</legend>
        <p class="ap-hint" id="wiz-pay-hint">You pay after checking your order.</p>
        <p class="ap-error ap-wizard__error" id="wiz-pay-error" hidden><svg class="ap-wizard__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><path d="M12 16.5h.01"/></svg><span class="ap-wizard__vh">Error:</span> <span data-ap-text></span></p>
          <label class="ap-wizard__option" for="wiz-pay-upi">
            <input type="radio" id="wiz-pay-upi" name="pay" value="upi" />
            <span class="ap-wizard__option-text"><span class="ap-wizard__option-name">UPI</span> <span class="ap-wizard__option-note">Any UPI app, on the next screen</span></span>
          </label>
          <label class="ap-wizard__option" for="wiz-pay-card">
            <input type="radio" id="wiz-pay-card" name="pay" value="card" />
            <span class="ap-wizard__option-text"><span class="ap-wizard__option-name">Card</span> <span class="ap-wizard__option-note">Debit or credit card</span></span>
          </label>
          <label class="ap-wizard__option" for="wiz-pay-cod">
            <input type="radio" id="wiz-pay-cod" name="pay" value="cod" />
            <span class="ap-wizard__option-text"><span class="ap-wizard__option-name">Cash on delivery</span> <span class="ap-wizard__option-note">₹40 handling fee</span></span>
          </label>
      </fieldset>
    </fieldset>

    <fieldset class="ap-wizard__panel" data-ap-panel="4" hidden>
      <legend class="ap-wizard__legend"><h4 class="ap-wizard__h" id="wiz-h-4" tabindex="-1"><span class="ap-wizard__kicker">Step 4 of 4</span> Check your order</h4></legend>
      <div class="ap-wizard__review">
        <div class="ap-wizard__group">
          <div class="ap-wizard__group-top"><h5 class="ap-wizard__group-name">Your details</h5><button type="button" class="ap-wizard__change" data-ap-goto="1">Change<span class="ap-wizard__vh"> your details</span></button></div>
          <dl class="ap-wizard__list">
          <div class="ap-wizard__row"><dt>Name</dt><dd data-ap-show="name"></dd></div>
          <div class="ap-wizard__row"><dt>Email</dt><dd data-ap-show="email"></dd></div>
          <div class="ap-wizard__row"><dt>Mobile</dt><dd data-ap-show="tel"></dd></div>
          </dl>
        </div>
        <div class="ap-wizard__group">
          <div class="ap-wizard__group-top"><h5 class="ap-wizard__group-name">Delivery</h5><button type="button" class="ap-wizard__change" data-ap-goto="2">Change<span class="ap-wizard__vh"> delivery</span></button></div>
          <dl class="ap-wizard__list">
          <div class="ap-wizard__row"><dt>Address</dt><dd data-ap-show="address"></dd></div>
          <div class="ap-wizard__row"><dt>Speed</dt><dd data-ap-show="speed"></dd></div>
          </dl>
        </div>
        <div class="ap-wizard__group">
          <div class="ap-wizard__group-top"><h5 class="ap-wizard__group-name">Payment</h5><button type="button" class="ap-wizard__change" data-ap-goto="3">Change<span class="ap-wizard__vh"> payment</span></button></div>
          <dl class="ap-wizard__list">
          <div class="ap-wizard__row"><dt>Method</dt><dd data-ap-show="pay"></dd></div>
          </dl>
        </div>
        <p class="ap-wizard__total"><span>Total to pay</span> <strong data-ap-total translate="no">₹3,450</strong></p>
      </div>
    </fieldset>
  </div>

  <div class="ap-wizard__foot">
    <p class="ap-wizard__status" role="status"><svg class="ap-wizard__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg><span data-ap-done></span></p>
    <button type="button" class="ap-btn" data-ap-back hidden>
      <svg class="ap-btn__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m15 6-6 6 6 6"/></svg>
      Back
    </button>
    <button type="submit" class="ap-btn ap-btn--primary" data-ap-next>Continue</button>
  </div>
</form>

WCAG 2.2

What it meets

The success criteria this pattern takes care of, and how.

  • 1.3.1 Info and Relationships Level A

    Each step is a fieldset named by its heading, choices are grouped with their own legend, and the progress is an ordered list with the current step marked.

  • 1.3.5 Identify Input Purpose Level AA

    Name, email, mobile, address, town and PIN code carry autocomplete tokens, so the browser can fill each step.

  • 2.4.3 Focus Order Level A

    When the step changes, focus goes to the new step's heading, not to the top of the page or to a button that may no longer be there.

  • 2.4.6 Headings and Labels Level AA

    Every step's heading says where you are in words, Step 2 of 4, then what the step is for.

  • 3.3.1 Error Identification Level A

    Problems are found before leaving a step, marked aria-invalid, written under each field and listed in a summary that takes focus.

  • 3.3.4 Error Prevention (Legal, Financial, Data) Level AA

    Nothing is ordered until the review: every answer is shown there with a Change button, and the order is placed only from that step.

  • 3.3.7 Redundant Entry Level A

    Going back, or changing a part from the review, keeps everything already entered; nothing has to be typed twice.

  • 4.1.2 Name, Role, Value Level A

    The current step carries aria-current="step", and finished steps say Done in text, not only with a tick.

Usage

When to use it

Use it

  • Long tasks that fall into clear parts, like a checkout, an application or setting up an account.
  • Questions whose answers change what comes next, so asking them a part at a time keeps each screen short.

Use something else

  • Short forms that fit on one screen: splitting them adds clicks and hides the end from view.
  • Parts people need to see side by side to answer well: keep those on one page.
  • Steps that can be done in any order: use tabs or sections with headings instead.

Common failures

How it usually goes wrong

  • Focus left on the Continue button

    When the content changes and focus stays on the button, a screen reader user hears nothing and keyboard users must hunt for the new step. The new step's heading takes focus here.

  • Progress shown only by color

    A filled circle in a slightly different shade says nothing to many people. Finished steps here have a tick and the word Done, and the current step is marked aria-current="step".

  • Answers lost on going back

    Clearing a step when someone goes back makes them type it all again. The steps here are only hidden, so every answer stays.

  • Errors found only at the end

    Checking everything on the last step sends people back through every screen to find the problem. Each step is checked before moving on.

  • No way to check before ordering

    Placing the order straight from the payment step leaves no chance to spot a wrong address. The review shows every answer with a Change button.

  • Clickable step indicators

    Steps in the progress list that act as links let people skip unchecked steps and give the list two jobs. Here it only shows progress; moving is done with Back, Continue and Change.

Notes

Building it

  • The heading sits inside the legend, so it names the fieldset and still appears in a screen reader's list of headings. It has tabindex="-1" so the script can focus it without adding a Tab stop.
  • Continue is the form's submit button, so Enter in any field moves on, and the checks run in one place. Back is type="button" so it never submits.
  • After a Change from the review, Continue reads Save and return to review and goes straight back, so a small fix does not mean walking through every step again.
  • On a site where each step is its own page, keep the same parts: the step in the page title and heading, a Back link, and the review's Change links.
  • The total is worked out from the answers and written as a number in its own element, so the words around it translate on their own.

Sources: WAI Tutorials: Multi-page forms · WAI Tutorials: Grouping controls · HTML: the legend element

Checked with axe in light and dark themes, at desktop and phone widths, and by keyboard. Not yet tested with every screen reader and browser pair. Report a correction