Modern HTML5 in Production · Semantics and Accessibility

Accessible Forms and Error Messages

Why users kept failing to register and abandoned the form, and how labels, descriptions, error association and focus management make forms usable for everyone.

Scenario

Example app: a registration form uses placeholders instead of labels to save space. When validation fails, the fields turn red and a generic "Please fix the errors" banner appears at the top.

What you see in production

  • Analytics show a 35% drop-off on the registration step; support tickets mention "I don't know what's wrong".
  • Screen-reader users hear "edit text, blank" for each field: the placeholder disappears once they type, and red borders aren't announced.
  • Users with memory or cognitive impairments lose the hint as soon as they start typing.

The naive code

<input type="text" placeholder="Email">
<input type="password" placeholder="Password (min 8 chars)">
<div class="banner">Please fix the errors</div>
<style>.invalid { border-color: red; }</style>

Reproduce it

The preview has the naive form and an accessible version. Submit both empty, then fill them in. Notice what tells you which field is wrong and why.

Placeholder-only vs labelled form with linked errorsTry it yourself
<style>
  form { border: 1px solid #ddd; padding: 8px 12px; margin: 8px 0; border-radius: 8px; }
  .invalid, [aria-invalid="true"] { border: 2px solid crimson; }
  .error { color: crimson; font-size: .9em; margin: 2px 0 8px; }
  .hint { color: #555; font-size: .9em; margin: 2px 0; }
  label { display: block; font-weight: 600; margin-top: 6px; }
</style>
<form id="naive" novalidate>
  <strong>Naive</strong><br>
  <input id="n-email" type="email" placeholder="Email"><br>
  <input id="n-pw" type="password" placeholder="Password (min 8 chars)"><br>
  <p id="n-banner"></p>
  <button>Register</button>
</form>
<form id="good" novalidate>
  <strong>Accessible</strong>
  <label for="g-email">Email</label>
  <input id="g-email" type="email" autocomplete="email" aria-describedby="g-email-err" required>
  <p class="error" id="g-email-err"></p>
  <label for="g-pw">Password</label>
  <p class="hint" id="g-pw-hint">At least 8 characters.</p>
  <input id="g-pw" type="password" autocomplete="new-password" minlength="8" aria-describedby="g-pw-hint g-pw-err" required>
  <p class="error" id="g-pw-err"></p>
  <button>Register</button>
</form>
<script>
  // Real pages use the form's "submit" event; this preview blocks form submission, so we listen for button clicks.
  document.querySelector("#naive button").addEventListener("click", (e) => {
    e.preventDefault();
    const bad = ["n-email", "n-pw"].filter((id) => !document.getElementById(id).checkValidity() || !document.getElementById(id).value);
    ["n-email", "n-pw"].forEach((id) => document.getElementById(id).classList.toggle("invalid", bad.includes(id)));
    document.getElementById("n-banner").textContent = bad.length ? "Please fix the errors" : "Sent";
  });
  document.querySelector("#good button").addEventListener("click", (e) => {
    e.preventDefault();
    let first = null;
    for (const [id, msg] of [["g-email", "Enter an email address like [email protected]."], ["g-pw", "Use at least 8 characters."]]) {
      const input = document.getElementById(id), ok = input.checkValidity();
      input.setAttribute("aria-invalid", String(!ok));
      document.getElementById(id + "-err").textContent = ok ? "" : msg;
      if (!ok && !first) first = input;
    }
    if (first) first.focus(); else alert("Sent");
  });
</script>

Root cause

  • A placeholder isn't a label: it disappears when typing, has low contrast, and isn't reliably announced as the field's name.
  • Errors were communicated only by colour and a generic banner, not connected to fields, so assistive technology couldn't announce them and nobody could tell what to fix.

The fix

From the W3C forms tutorial:

  • Every control has a visible <label for> (or wraps the input). Keep hints visible and connect them with aria-describedby.
  • On error: set aria-invalid="true", show a specific message next to the field and connect it with aria-describedby, and move focus to the first invalid field (or to an error summary with links to each field).
  • Don't rely on colour alone: add text and, ideally, an icon.
  • Use the right type and autocomplete values so browsers can fill fields (next module).

Trade-offs

  • Visible labels take space; floating-label designs can work if the label remains visible after typing.
  • Error summaries help long forms; inline messages help short ones; many forms use both.

Detect it in production

  • Track field-level validation failures and form abandonment per field.
  • Automated checks flag inputs without labels; test error states with a screen reader.

Key points

  • Labels, not placeholders. Hints visible and linked with aria-describedby.
  • Errors: specific text, linked to the field, aria-invalid, and focus moved to the first problem.
  • Never use colour alone to convey errors.

Practice questions

Why isn't a placeholder a substitute for a label?

Answer

It disappears when the user types, often has poor contrast, and isn't reliably announced as the field's accessible name. Use a visible label element associated with the input.

How do you make form errors accessible?

Answer

Show a specific text message next to the field, associate it with aria-describedby, set aria-invalid="true", don't rely on colour alone, and move focus to the first invalid field or an error summary.

What does aria-describedby do on an input?

Answer

It points to elements whose text describes the input (hints, error messages); screen readers read that description after the field's name and role.

Open in the app to mark lessons complete, take notes and track your progress.