Accessibility Developer Kit · Guide 12 / 20

Accessible Form

Semantic form structure, <fieldset> grouping, required-field conventions, and the error-summary pattern. The foundation chapters 13–16 build on — in Vanilla and React.

  • WCAG: 1.3.1 · 3.3.1 · 3.3.2 · 3.3.3 · 4.1.2
  • Level: Intermediate
  • Icons: lucide-react (AlertCircle)
1

What we are building

A form that is keyboard-first, screen-reader-first, and that handles errors with real focus management.

What we are building

This chapter covers the form-level patterns: the <form> element itself, grouping with <fieldset> + <legend>, marking required fields, and the error-summary pattern used by GOV.UK, GitHub and others. Chapters 13–16 go into each control (input, select, checkbox/radio, error message) in depth.

2

The 4 real-world variants

Switch between the inaccessible version and the accessible fix.

The 4 real-world variants

Variant A

Semantic form

Preferred
Live example

Press Enterfrom any input to submit. Browsers prefill name & email from the autocomplete hints.

HTML · Accessible
<!-- Real <form> + labels + autocomplete + submit button -->
<form action="/signup" method="post" novalidate>
  <label for="name">Full name</label>
  <input id="name" name="name" type="text" autocomplete="name" required>

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

  <button type="submit">Create account</button>
</form>

Real <form> gives you Enter-to-submit, native validation, and a submit event. Autocomplete attributes let browsers prefill.

Variant B

Fieldset grouping

For groups
Live example
Shipping speed

SR with fieldset: “Shipping speed, group, Standard, 1 of 3”. Without fieldset: just “Standard” — no context.

HTML · Accessible
<!-- fieldset + legend: SR announces group name on EVERY option -->
<fieldset>
  <legend>Shipping speed</legend>
  <label><input type="radio" name="speed" value="std"> Standard (5-7 days)</label>
  <label><input type="radio" name="speed" value="exp"> Express (2 days)</label>
  <label><input type="radio" name="speed" value="ovn"> Overnight</label>
</fieldset>

Required for radio and checkbox groups. SR announces the legend before every option so users know what group they are in.

Variant C

Required / optional

Preferred
Live example

indicates a required field

Visual “*” is aria-hidden — the browser announces “required” via the required attribute.

HTML · Accessible
<!-- Native required + visible asterisk + legend explaining it -->
<p><span aria-hidden="true">*</span> indicates a required field</p>

<label for="email">
  Email <span class="req" aria-hidden="true">*</span>
</label>
<input id="email" type="email" required autocomplete="email">

<!-- OR, when most fields are required, mark the exceptions -->
<label for="company">
  Company <span class="opt">(optional)</span>
</label>
<input id="company" type="text" autocomplete="organization">

Use native `required` + visible asterisk + a legend line. aria-required is redundant when `required` is set.

Variant D

Multi-field validation (focus first)

Scales to long forms
Live example

Submit empty — focus jumps to the first invalid field. SR announces the field’s error on focus.

HTML · Accessible
<!-- Error summary with role="alert", focus moved to it, links to each field -->
<form noValidate>
  <div
    id="err-summary"
    role="alert"
    tabindex="-1"
    aria-labelledby="err-summary-title"
    hidden
  >
    <strong id="err-summary-title">There are 2 problems with your submission</strong>
    <ul>
      <li><a href="#f-email">Email is required.</a></li>
      <li><a href="#f-password">Password must be at least 8 characters.</a></li>
    </ul>
  </div>

  <label for="f-email">Email</label>
  <input id="f-email" type="email" required
         aria-invalid="true"
         aria-describedby="f-email-err">
  <span id="f-email-err">Email is required.</span>

  <button type="submit">Sign up</button>
</form>

<!-- JS: on invalid submit, show summary, move focus to it,
     set aria-invalid on failing fields -->

On invalid submit, focus the first invalid field. SR reads its error on focus (via aria-describedby). Short forms (3-5 fields) can alternatively use the GOV.UK top-summary pattern.

3

Flagship implementation

A reusable AccessibleForm with validation + error summary wired up.

Flagship implementation

AccessibleForm.jsx
import React, { useRef, useState } from "react";
import "./AccessibleForm.css";

/**
 * AccessibleForm — form shell with:
 *  - noValidate (we control messages)
 *  - error-summary pattern (focus moves to it on invalid submit)
 *  - automatic aria-invalid + aria-describedby wiring per field
 */
export default function AccessibleForm({
  fields,              // [{ id, label, type, required, autocomplete, validate, help }]
  onSubmit,
  submitLabel = "Submit",
}) {
  const [values, setValues] = useState(
    Object.fromEntries(fields.map((f) => [f.id, ""]))
  );
  const [errors, setErrors] = useState({});
  const summaryRef = useRef(null);

  function runValidate() {
    const next = {};
    for (const f of fields) {
      if (f.required && !values[f.id]) {
        next[f.id] = `${f.label} is required.`;
      } else if (f.validate) {
        const msg = f.validate(values[f.id]);
        if (msg) next[f.id] = msg;
      }
    }
    return next;
  }

  function handleSubmit(e) {
    e.preventDefault();
    const next = runValidate();
    setErrors(next);
    if (Object.keys(next).length) {
      setTimeout(() => summaryRef.current?.focus(), 0);
    } else {
      onSubmit?.(values);
    }
  }

  const errorKeys = Object.keys(errors);

  return (
    <form noValidate onSubmit={handleSubmit}>
      {errorKeys.length > 0 && (
        <div
          ref={summaryRef}
          role="alert"
          tabIndex={-1}
          className="err-summary"
          aria-labelledby="err-summary-title"
        >
          <strong id="err-summary-title">
            There {errorKeys.length === 1 ? "is 1 problem" : `are ${errorKeys.length} problems`}
          </strong>
          <ul>
            {errorKeys.map((k) => (
              <li key={k}><a href={`#${k}`}>{errors[k]}</a></li>
            ))}
          </ul>
        </div>
      )}

      {fields.map((f) => (
        <label key={f.id} className="field" htmlFor={f.id}>
          <span>
            {f.label}
            {f.required && <span className="req" aria-hidden="true"> *</span>}
          </span>
          <input
            id={f.id}
            type={f.type || "text"}
            autoComplete={f.autocomplete}
            required={f.required}
            aria-invalid={!!errors[f.id]}
            aria-describedby={[
              f.help ? `${f.id}-help` : null,
              errors[f.id] ? `${f.id}-err` : null,
            ].filter(Boolean).join(" ") || undefined}
            value={values[f.id]}
            onChange={(e) => setValues((v) => ({ ...v, [f.id]: e.target.value }))}
          />
          {f.help && <span id={`${f.id}-help`} className="help">{f.help}</span>}
          {errors[f.id] && (
            <span id={`${f.id}-err`} className="err">{errors[f.id]}</span>
          )}
        </label>
      ))}

      <button type="submit">{submitLabel}</button>
    </form>
  );
}
Usage · SignupPage.jsx
import AccessibleForm from "./AccessibleForm";

const FIELDS = [
  { id: "name",     label: "Full name", autocomplete: "name",         required: true },
  { id: "email",    label: "Email",     autocomplete: "email",        required: true, type: "email",
    validate: v => /^\S+@\S+\.\S+$/.test(v) ? null : "Enter a valid email." },
  { id: "password", label: "Password",  autocomplete: "new-password", required: true, type: "password",
    validate: v => v.length >= 8 ? null : "Password must be at least 8 characters.",
    help: "At least 8 characters." },
];

export default function SignupPage() {
  return (
    <AccessibleForm
      fields={FIELDS}
      submitLabel="Create account"
      onSubmit={(values) => fetch("/api/signup", {
        method: "POST", body: JSON.stringify(values)
      })}
    />
  );
}
4

Keyboard behavior

Native forms get keyboard behavior right out of the box.

Keyboard behavior

ActionExpected result
TabMoves to the next form control
Enter (text input)Submits the form (if a submit button exists)
Space (checkbox / radio)Toggles / selects the control
↑ ↓ (radio group)Moves between radios inside the fieldset
On invalid submitFocus moves to the error summary (role=alert)
5

Screen reader expectations

SR users rely on labels, groups, and announced errors.

Screen reader expectations

  • Input: “Email, required, edit text, blank”
  • Radio in fieldset: “Shipping speed, group, Standard, radio button, 1 of 3, not selected”
  • Invalid input: “Email, invalid, edit, Enter a valid email like you@example.com”
  • Error summary on submit: “Alert, There are 2 problems with your submission, link, Email is required...”
6

WCAG mapping

Why each rule matters here.

WCAG mapping

Success CriterionWhy it matters
1.3.1 Info and Relationships (A)Labels, legends, and group associations must be programmatic, not just visual.
3.3.1 Error Identification (A)Errors must be identified in text, not color alone.
3.3.2 Labels or Instructions (A)Every input needs a visible label; instructions should be visible too.
3.3.3 Error Suggestion (AA)Suggest how to fix the error (format examples, hints).
4.1.2 Name, Role, Value (A)Each control exposes label + role + current value.
7

Testing approach

Framework-agnostic patterns. Pick the one that matches your stack.

Testing approach

Pattern: fill fields → submit → assert error summary gets focus → assert aria-invalid + aria-describedby on failing fields.

Jest + Testing Library + jest-axe

AccessibleForm.test.jsx
// Jest + @testing-library/react + jest-axe
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe, toHaveNoViolations } from "jest-axe";
import AccessibleForm from "./AccessibleForm";

expect.extend(toHaveNoViolations);

const FIELDS = [
  { id: "email", label: "Email", type: "email", required: true, autocomplete: "email" },
];

test("surfaces an error summary on invalid submit", async () => {
  const user = userEvent.setup();
  render(<AccessibleForm fields={FIELDS} submitLabel="Go" />);
  await user.click(screen.getByRole("button", { name: "Go" }));
  const alert = await screen.findByRole("alert");
  expect(alert).toHaveTextContent(/email is required/i);
  expect(alert).toHaveFocus();
});

test("sets aria-invalid on failing field + links to error message", async () => {
  const user = userEvent.setup();
  render(<AccessibleForm fields={FIELDS} submitLabel="Go" />);
  await user.click(screen.getByRole("button", { name: "Go" }));
  const email = screen.getByLabelText(/email/i);
  expect(email).toHaveAttribute("aria-invalid", "true");
  expect(email).toHaveAccessibleDescription(/email is required/i);
});

test("has no axe violations", async () => {
  const { container } = render(<AccessibleForm fields={FIELDS} submitLabel="Go" />);
  expect(await axe(container)).toHaveNoViolations();
});

Vitest + Testing Library

AccessibleForm.test.jsx
// Vitest + @testing-library/react
import { expect, test } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import AccessibleForm from "./AccessibleForm";

test("submit fires onSubmit with valid data", async () => {
  const user = userEvent.setup();
  const onSubmit = vi.fn();
  render(
    <AccessibleForm
      fields={[{ id: "email", label: "Email", type: "email", required: true }]}
      onSubmit={onSubmit}
      submitLabel="Go"
    />
  );
  await user.type(screen.getByLabelText(/email/i), "you@example.com");
  await user.click(screen.getByRole("button", { name: "Go" }));
  expect(onSubmit).toHaveBeenCalledWith({ email: "you@example.com" });
});

Playwright + @axe-core/playwright

form.spec.js
// Playwright + @axe-core/playwright
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test("form page has no serious a11y violations", async ({ page }) => {
  await page.goto("/en/accessible-form");
  const results = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa", "wcag22aa"])
    .analyze();
  const serious = results.violations.filter(
    (v) => v.impact === "critical" || v.impact === "serious"
  );
  expect(serious).toEqual([]);
});

test("invalid submit moves focus to the error summary", async ({ page }) => {
  await page.goto("/en/accessible-form");
  await page.getByRole("button", { name: /sign up/i }).click();
  const alert = page.getByRole("alert");
  await expect(alert).toBeVisible();
  await expect(alert).toBeFocused();
});
8

Manual QA checklist

Tick each item before shipping.

Manual QA checklist

  • Form wrapped in a real <form> element with a submit button
  • Every input has a visible <label for="...">
  • Related fields grouped in <fieldset> + <legend>
  • Required fields use native required
  • Required marker ("*") explained in form intro
  • autocomplete set on name/email/phone/address/cc fields
  • Inputs meet 44×44 target size
  • Error summary with role="alert" on invalid submit
  • Focus moves to the summary when it appears
  • Zero critical / serious axe violations
9

Common mistakes

The form bugs we see on nearly every audit.

Common mistakes

  • Divs with onClick for submit. Breaks Enter-key submit and SR expectations.
  • Placeholder as label. Disappears on focus; low contrast; untranslatable context.
  • Red asterisk only, no explanation. SR users don’t know what the * means.
  • No autocomplete. Users retype their name and email every signup.
  • Error only shown as red border. Fails 1.4.1 and 3.3.1. Add text.
  • No focus to error summary. SR users don’t know anything happened on submit.
10

Download & next

Grab the code bundle, or jump to the next guide.

Download and next

© 2026 Aria Probe. All rights reserved
Powered by techtipsall Logo