---
title: 'Form Patterns'
source: 'https://academia.sh/en/courses/accessible-patterns/form-patterns'
course: 'Accessible Component Patterns'
language: en
updated: '2026-08-19T05:19:51+00:00'
license: 'CC BY-SA 4.0'
---

# Form Patterns

The criterion for grouping fields, focus and announcement management in a multi-step form, save and undo behavior, and comparing two layouts with a rule check.

The previous four lessons specified components one at a time. The borrowing form,
though, carries a button, three fields, a radio group, and a dropdown list; even if each
one is built correctly on its own, the form as a whole may not work. Which group a field
belongs to, how a step change is announced in a form split into three steps, and whether
submission can be undone are not written in any component specification.

This lesson specifies the form as a whole. What is measured is the defects that
individually correct components can produce together.

## The Splitting Criterion

A form is not split because it has many fields. The splitting criterion is that the
fields form **decision sets independent of one another**: in the borrowing form, record
selection, member information, and the duration decision are separate sets; each can be
validated on its own, and the user does not move to the next without completing one.

When the criterion is not met, splitting causes harm. Spreading a five-field
correction-report form across three pages keeps the user from seeing all of it at once
and adds a burden that requires focus management at every transition. Short forms stay
in a single view.

## Field Grouping

The criterion for grouping is that the fields answer a **shared question**. "Return
date" and "Borrow duration" answer a shared question; "Membership number" and "Borrow
duration" do not. Standing close together on screen is not a grouping justification.

Fields that share a question are wrapped with `fieldset` and `legend`; the group's name
is the question itself. A group carrying only one field is not built — that field's
label is already the question, and an additional group name doubles the length of the
announcement.

```html
<h2 id="step-2" tabindex="-1">Member information</h2>
<p id="step-status" role="status">Step 2 / 3</p>

<form action="/borrow/step-2" method="post">
  <fieldset>
    <legend>Notification method</legend>
    <p><label><input type="radio" name="notification" value="email" checked> Email</label></p>
    <p><label><input type="radio" name="notification" value="none"> No notification</label></p>
  </fieldset>

  <p><label for="member-no">Membership number</label>
     <input id="member-no" name="member-no" required inputmode="numeric"
            aria-describedby="member-help">
     <span id="member-help">The 8-digit number on the card.</span></p>

  <p><button type="submit">Next</button>
     <button type="button" name="back">Back</button></p>
</form>
```

The order and type of the buttons are part of the specification. The "Next" button is
the form's **first submit button**; a user who presses Enter while in a text field runs
this button. The "Back" button carries `type="button"`; if it did not, implicit
submission would take the form backward, and the user would lose a step.

## A Step Change Is Not a Page Change

In a transition made by refreshing the page, focus returns to the start of the document,
and the user enters the new document from the top. In a transition made without
refreshing the page, nothing happens on its own: the content on screen changes, focus
stays on the old button, and for a user who cannot see the screen, it looks as if nothing
happened.

The specification requires three things: focus moves to the new step's heading, the
transition is announced with a polite live region, and the step indicator states which
step it is in text. The heading carrying `tabindex="-1"` is so it can take focus without
entering the tab order.

The script below runs the same scenario under two layouts and checks four rules.

```js
// form.mjs — auditing focus management, announcements, and value preservation in a multi-step form

const STEPS = [
  { name: "Record confirmation", heading: "step-1", fields: [
    { id: "record", label: "Record number", required: true,
      rule: (v) => /^K-\d{3}$/.test(v), message: "Record number must be in the K-000 format." },
  ] },
  { name: "Member information", heading: "step-2", fields: [
    { id: "member-no", label: "Membership number", required: true,
      rule: (v) => /^\d{8}$/.test(v), message: "Membership number must be 8 digits." },
    { id: "email", label: "Notification address", required: false,
      rule: (v) => v === "" || v.includes("@"), message: "Enter a valid email address." },
  ] },
  { name: "Duration and confirmation", heading: "step-3", fields: [
    { id: "duration", label: "Borrow duration", required: true,
      rule: (v) => ["14", "21", "28"].includes(v), message: "Select a borrow duration." },
  ] },
];

const initial = () => ({
  step: 0, values: {}, errors: [], focus: "input#record", announcement: "", done: false,
});

function validate(step, values) {
  const h = [];
  for (const a of STEPS[step].fields) {
    const v = values[a.id] ?? "";
    if (a.required && v === "") h.push([a.id, a.label, "This field is required."]);
    else if (v !== "" && !a.rule(v)) h.push([a.id, a.label, a.message]);
  }
  return h;
}

// layout: movesFocus, announcesTransition, preservesValues
function machine(layout) {
  const focusTo = (target, previous) => (layout.movesFocus ? target : previous);
  const announce = (m) => (layout.announcesTransition ? m : "");
  return (d, e) => {
    if (e.kind === "input") return { ...d, values: { ...d.values, [e.id]: e.value } };
    if (e.kind === "back") {
      if (d.step === 0) return d;
      const y = d.step - 1;
      return { ...d, step: y, errors: [],
        values: layout.preservesValues ? d.values : {},
        focus: focusTo("h2#" + STEPS[y].heading, d.focus),
        announcement: announce(`Step ${y + 1} / ${STEPS.length}: ${STEPS[y].name}`) };
    }
    if (e.kind === "next" || e.kind === "submit") {
      const errors = validate(d.step, d.values);
      if (errors.length > 0) {
        return { ...d, errors, focus: focusTo("div#error-summary", d.focus),
          announcement: announce(`${errors.length} field(s) need correction`) };
      }
      if (e.kind === "submit" && d.step === STEPS.length - 1) {
        return { ...d, errors: [], done: true, focus: focusTo("h2#result", d.focus),
          announcement: announce("Borrow request recorded") };
      }
      const y = Math.min(d.step + 1, STEPS.length - 1);
      return { ...d, step: y, errors: [],
        focus: focusTo("h2#" + STEPS[y].heading, d.focus),
        announcement: announce(`Step ${y + 1} / ${STEPS.length}: ${STEPS[y].name}`) };
    }
    return d;
  };
}

const SCENARIO = [
  { kind: "input", id: "record", value: "K-118" },
  { kind: "next" },
  { kind: "input", id: "member-no", value: "1234" },
  { kind: "next" },
  { kind: "input", id: "member-no", value: "10045512" },
  { kind: "next" },
  { kind: "back" },
  { kind: "next" },
  { kind: "input", id: "duration", value: "21" },
  { kind: "submit" },
];

const FOCUSABLE = new Set([
  "input#record", "div#error-summary", "h2#step-1", "h2#step-2", "h2#step-3", "h2#result",
]);

function run(heading, layout, logTrace) {
  const next = machine(layout);
  let d = initial();
  const trace = [];
  console.log(heading);
  if (logTrace) console.log("  action".padEnd(24) + "step".padEnd(31) + "focus".padEnd(20) + "announcement");
  for (const e of SCENARIO) {
    const previous = d;
    d = next(d, e);
    trace.push({ e, previous, d });
    if (!logTrace) continue;
    const label = e.kind === "input" ? `input ${e.id}` : e.kind;
    const stepName = d.done ? "result" : `${d.step + 1}/${STEPS.length} ${STEPS[d.step].name}`;
    console.log("  " + label.padEnd(22) + stepName.padEnd(31) + d.focus.padEnd(20) +
      (d.announcement || "-"));
  }

  const findings = [];
  for (const { e, previous, d: s } of trace) {
    if (e.kind === "input") continue;
    if (s.focus === previous.focus && s.step !== previous.step)
      findings.push(`${e.kind}: step changed, focus did not move`);
    if (s.errors.length > 0 && s.focus !== "div#error-summary")
      findings.push(`${e.kind}: error present, focus did not move to the summary`);
    if (!FOCUSABLE.has(s.focus))
      findings.push(`${e.kind}: focus target is not focusable (${s.focus})`);
    if (s.announcement === "") findings.push(`${e.kind}: transition was not announced`);
    for (const k of Object.keys(previous.values)) {
      if (previous.values[k] !== s.values[k])
        findings.push(`${e.kind}: value of the ${k} field was lost`);
    }
  }
  console.log(`  findings: ${findings.length}`);
  for (const b of findings) console.log("    " + b);
  return trace;
}

run("flawed layout (focus does not move, transitions are not announced, values are cleared going back):",
  { movesFocus: false, announcesTransition: false, preservesValues: false }, false);

console.log("");
const trace = run("fixed layout:",
  { movesFocus: true, announcesTransition: true, preservesValues: true }, true);

const failed = trace.find(({ d }) => d.errors.length > 0).d;
console.log("\nsummary links in the failed transition:");
for (const [id, label, message] of failed.errors) {
  console.log(`  <a href="#${id}">${label}: ${message}</a>`);
}
```

```
flawed layout (focus does not move, transitions are not announced, values are cleared going back):
  findings: 14
    next: step changed, focus did not move
    next: transition was not announced
    next: error present, focus did not move to the summary
    next: transition was not announced
    next: step changed, focus did not move
    next: transition was not announced
    back: step changed, focus did not move
    back: transition was not announced
    back: value of the record field was lost
    back: value of the member-no field was lost
    next: error present, focus did not move to the summary
    next: transition was not announced
    submit: error present, focus did not move to the summary
    submit: transition was not announced

fixed layout:
  action                step                           focus               announcement
  input record          1/3 Record confirmation        input#record        -
  next                  2/3 Member information         h2#step-2           Step 2 / 3: Member information
  input member-no       2/3 Member information         h2#step-2           Step 2 / 3: Member information
  next                  2/3 Member information         div#error-summary   1 field(s) need correction
  input member-no       2/3 Member information         div#error-summary   1 field(s) need correction
  next                  3/3 Duration and confirmation  h2#step-3           Step 3 / 3: Duration and confirmation
  back                  2/3 Member information         h2#step-2           Step 2 / 3: Member information
  next                  3/3 Duration and confirmation  h2#step-3           Step 3 / 3: Duration and confirmation
  input duration        3/3 Duration and confirmation  h2#step-3           Step 3 / 3: Duration and confirmation
  submit                result                         h2#result           Borrow request recorded
  findings: 0

summary links in the failed transition:
  <a href="#member-no">Membership number: Membership number must be 8 digits.</a>
```

The flawed layout produces fourteen findings, and two of them are cascading defects:
because values are cleared going back, the next two transitions also fail to pass
validation. The only thing visible on screen is "the form going blank when you go back";
the remaining twelve findings never appear on screen.

Two rows in the fixed run's trace need attention. In the fourth row, validation fails and
focus goes to the error summary; the step number does not change. In the seventh row,
going back is also a step change and requires the **same** focus and announcement
handling as going forward; leaving the backward direction silent is a commonly skipped
case.

## Keyboard Contract

| Key | Behavior |
|---|---|
| `Tab` / `Shift+Tab` | Moves between fields in source order. |
| `Enter` (single-line field) | Runs the form's first submit button. |
| `Enter` (multi-line field) | Inserts a line break; does not submit the form. |
| `Enter` (on a button) | Runs the focused button. |

The contract's constraint is **which button** implicit submission runs. If the form's
first submit button is "Back," a user who presses Enter in a field falls back a step.
Every button that does not carry submission behavior is written with `type="button"`.

## Saving and Undo

A borrow request produces a record; it is a binding operation on the user's own data.
The 3.3.4 Error Prevention (Legal, Financial, Data) criterion requires **at least one**
of three routes for this kind of submission: submission has to be reversible, entered
data has to be checkable and correctable, or it has to be confirmed before submission.

In the three-step form, the second route is established on its own: the last step shows
all entered values as a summary and carries a link back to the relevant step next to each
row. It is not enough for the summary to be plain text; the user has to be able to reach
the field they want to correct with an action.

Two additional rules are written into the specification. Leaving the page while unsaved
changes exist is met with a warning; when the warning is a modal dialog, it requires
focus management, and this is the next topic's responsibility. If there is a session
timeout, 2.2.1 Timing Adjustable comes into play: the user has to be able to extend the
time, or the data has to be preserved. A twenty-minute session silently wiping a long
form removes this criterion.

## Measurable Constraints

**3.3.2 Labels or Instructions** requires a label or instruction on every field; **3.3.1
Error Identification** requires the erroneous field to be identified in text; **3.3.3
Error Suggestion** requires a correction suggestion to be given when known. The
"Membership number must be 8 digits" message in the check is the counterpart of this
third criterion: it does not stop at reporting the error, it states the correct format.

**1.3.5 Identify Input Purpose** requires the purpose to be declared programmatically on
fields that collect the user's own information; the name and notification address
fields in the borrowing form fall within this scope.

**2.4.6 Headings and Labels** requires step headings to actually describe the content:
"Step 2" is not a heading, "Member information" is the heading, and the step number is
given in separate text.

## Common Mistake

**The step indicator being only visual.** A filling bar or three dots that change color
say nothing to a user who cannot see the screen. Catching it means checking whether the
indicator's text equivalent is present in the tree.

**Leaving the error summary where it was built.** When the summary is built but focus is
not moved, the user cannot learn that submission failed. Catching it is the check's
second rule: whether focus moves to the summary when an error exists is tested.

**Leaving the backward direction silent.** The focus and announcement handling
established for going forward is also required going back. Catching it means running
both directions through the same rule set.

**Building a single-field group.** Wrapping one field in a `fieldset` announces the
field's name twice, together with the group name. Catching it means counting the number
of fields inside the group.

## Summary

- A form is split by independent decision sets, not by field count; short forms stay in
  a single view.
- The criterion for grouping is that fields answer a shared question; single-field groups
  are not built.
- A step change requires three operations: moving focus to the new step's heading,
  announcing the transition with polite priority, and giving the step indicator in text.
  Going back is also a step change.
- The form's first submit button runs on implicit submission; buttons that do not carry
  submission behavior are written with `type="button"`.
- 3.3.4 requires at least one of the routes of undo, correction, or confirmation for
  binding submissions; the correctable summary on the last step establishes the second
  route.
- Most of the findings the check produces are not visible on screen; focus and
  announcement defects surface only through rule testing.

## Next Step

Every component in this topic ran an action: running a search, applying a filter,
submitting a form. The record titles in the results table, by contrast, do a different
job — they take the user to another resource. The two can be made to look alike on
screen, but their keyboard contracts, their roles in the tree, and what the user expects
from them differ. The next lesson ties this distinction to a rule and shows which
concrete breakages the confusion produces.
