caduh

Forms That Don’t Fail Users: Validation UX, Accessibility, and Error Patterns

7 min read

A production-ready playbook for building forms that are fast, inclusive, and resilient — with copy-paste patterns for HTML, React, and your APIs.

Forms That Don’t Fail Users

Validation UX, accessibility, and error patterns — with code

Build forms that ship conversions, don’t ship frustration. Validate in ways that guide, not gate. Always keep the server as source of truth and never lose what the user typed.


TL;DR

  • Validate on blur + on submit (not on every keystroke). Format on blur, not while typing.
  • Inline errors next to fields + a top error summary with links; focus moves to the first invalid field.
  • Use real <label for>, aria-invalid="true", aria-describedby for help/error IDs, and role="alert" / aria-live for dynamic errors.
  • Server validates everything and returns a field error map; the client only helps. Preserve input on errors.
  • Avoid color-only signals; write clear, specific error copy: “Enter a valid email, like [email protected].”
  • Respect i18n and don’t over-constrain names, addresses, and phone numbers.
  • In multi-step forms, save drafts and let users resume. Never wipe a form.

Core Principles

  1. Predictable timing: Validate on blur and submit. Show constraints before an error happens (e.g., password rules).
  2. Respect the typing flow: Don’t block keystrokes. Prefer inputmode, autocomplete, autocapitalize to help mobile keyboards.
  3. One truth (the server): The server checks everything, always. Client hints are fallible.
  4. No data loss: Keep values on error. Echo back what was sent, not defaults.
  5. Not just color: Always pair color with text/icon.
  6. First error gets focus: Move focus to the first invalid field and explain how to fix it.
  7. Fast feedback, not noisy feedback: Debounce async checks (e.g., username), announce results via aria-live="polite".

Anatomy of an Accessible Error Experience

  • Inline error below the field (short, action-oriented).
  • Error summary above the form: lists errors with anchor links to fields; uses role="alert" and tabindex="-1" so you can focus it.
  • Focus strategy on submit:
    1. Move focus to error summary;
    2. Then focus the first invalid field so screen-reader users hear context.

Baseline HTML Pattern (Progressive Enhancement)

<form novalidate method="post" action="/signup" aria-describedby="form-help">
  <p id="form-help">Fields marked * are required.</p>

  <div class="field">
    <label for="email">Email *</label>
    <input id="email" name="email" type="email" required
           autocomplete="email" inputmode="email"
           aria-describedby="email-hint email-error">
    <p id="email-hint" class="hint">We’ll send a confirmation.</p>
    <p id="email-error" class="error" hidden>Enter a valid email address, like [email protected].</p>
  </div>

  <div class="field">
    <label for="password">Password *</label>
    <input id="password" name="password" type="password" required
           aria-describedby="pw-hint pw-error" minlength="8">
    <p id="pw-hint" class="hint">At least 8 characters, including a number.</p>
    <p id="pw-error" class="error" hidden>Password must be at least 8 characters and include a number.</p>
  </div>

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

  <div id="error-summary" class="error-summary" role="alert" tabindex="-1" hidden>
    <h2>There’s a problem</h2>
    <ul>
      <!-- <li><a href="#email">Enter a valid email</a></li> -->
    </ul>
  </div>
</form>

Why this works

  • Real labels → accessible names.
  • aria-describedby ties hints and error text to the field.
  • Error summary can be focused and read out.
  • novalidate lets your JS/server own UX (you can keep native too; decide per project).

React + Zod + React Hook Form (Best of Both)

// SignupForm.tsx
import { useEffect, useRef } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";

const Schema = z.object({
  email: z.string().email("Enter a valid email, like [email protected]."),
  password: z.string()
    .min(8, "Password must be at least 8 characters.")
    .regex(/[0-9]/, "Include at least one number."),
});

type FormData = z.infer<typeof Schema>;

export default function SignupForm() {
  const summaryRef = useRef<HTMLDivElement>(null);
  const { register, handleSubmit, formState: { errors, isSubmitting }, setFocus } =
    useForm<FormData>({ resolver: zodResolver(Schema), mode: "onBlur" });

  useEffect(() => {
    const fields = Object.keys(errors);
    if (fields.length) {
      summaryRef.current?.removeAttribute("hidden");
      summaryRef.current?.focus();
      setFocus(fields[0] as keyof FormData, { shouldSelect: true });
    }
  }, [errors, setFocus]);

  const onSubmit = async (data: FormData) => {
    // POST to server; expect { fieldErrors?: Record<string,string>, formError?: string }
    const res = await fetch("/api/signup", { method: "POST", body: JSON.stringify(data) });
    if (!res.ok) {
      // Map server errors back into your form with setError(...)
      // Leave values intact so the user can fix and resubmit.
    }
  };

  return (
    <form noValidate onSubmit={handleSubmit(onSubmit)} aria-describedby="form-help">
      <p id="form-help">Fields marked * are required.</p>

      <div className="field">
        <label htmlFor="email">Email *</label>
        <input id="email" type="email"
          aria-invalid={!!errors.email || undefined}
          aria-describedby="email-hint email-error"
          autoComplete="email" inputMode="email" {...register("email")} />
        <p id="email-hint" className="hint">We’ll send a confirmation.</p>
        <p id="email-error" className="error" role="alert" hidden={!errors.email}>
          {errors.email?.message}
        </p>
      </div>

      <div className="field">
        <label htmlFor="password">Password *</label>
        <input id="password" type="password"
          aria-invalid={!!errors.password || undefined}
          aria-describedby="pw-hint pw-error" {...register("password")} />
        <p id="pw-hint" className="hint">At least 8 characters, including a number.</p>
        <p id="pw-error" className="error" role="alert" hidden={!errors.password}>
          {errors.password?.message}
        </p>
      </div>

      <button type="submit" disabled={isSubmitting}>Create account</button>

      <div id="error-summary" ref={summaryRef} className="error-summary" role="alert" tabIndex={-1} hidden>
        <h2>There’s a problem</h2>
        <ul>
          {errors.email && <li><a href="#email">Fix your email</a></li>}
          {errors.password && <li><a href="#password">Fix your password</a></li>}
        </ul>
      </div>
    </form>
  );
}

Notes

  • mode: "onBlur" keeps validation calm.
  • aria-invalid only when there’s an error.
  • Keep the server response shape predictable so you can map field errors (e.g., { fieldErrors: { email: "..." } }).

Server Validation (Authoritative)

Node + Ajv (JSON Schema)

import Ajv from "ajv";
const ajv = new Ajv({ allErrors: true, removeAdditional: true });
const schema = {
  type: "object",
  required: ["email", "password"],
  properties: {
    email: { type: "string", format: "email" },
    password: { type: "string", minLength: 8, pattern: ".*[0-9].*" }
  },
  additionalProperties: false
};
const validate = ajv.compile(schema);

app.post("/api/signup", (req, res) => {
  const ok = validate(req.body);
  if (!ok) {
    const fieldErrors: Record<string,string> = {};
    for (const e of validate.errors ?? []) {
      const field = e.instancePath.replace(/^\//, "");
      if (field) fieldErrors[field] = "Check this field.";
    }
    return res.status(400).json({ fieldErrors });
  }
  // ... create user
  res.status(201).json({ ok: true });
});

Python + FastAPI + Pydantic

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, EmailStr, constr

app = FastAPI()

class Signup(BaseModel):
    email: EmailStr
    password: constr(min_length=8, pattern=r".*[0-9].*")

@app.post("/api/signup")
def signup(p: Signup):
    # If invalid, FastAPI returns 422 with details
    return {"ok": True}

Async Validation (e.g., “Is username taken?”)

import { useState, useEffect } from "react";

function UsernameField({ register, error }: any) {
  const [value, setValue] = useState("");
  const [status, setStatus] = useState<"idle"|"checking"|"ok"|"taken">("idle");

  useEffect(() => {
    if (!value) return;
    const t = setTimeout(async () => {
      setStatus("checking");
      const r = await fetch(`/api/username/${encodeURIComponent(value)}`);
      setStatus(r.ok ? "ok" : "taken");
    }, 400); // debounce
    return () => clearTimeout(t);
  }, [value]);

  return (
    <div className="field">
      <label htmlFor="username">Username</label>
      <input id="username" {...register("username")} onChange={e => setValue(e.target.value)}
        aria-describedby="username-status username-error" aria-invalid={error ? true : undefined} />
      <p id="username-status" aria-live="polite">
        {status === "checking" && "Checking availability..."}
        {status === "ok" && "Available"}
        {status === "taken" && "Taken — try a different one."}
      </p>
      <p id="username-error" className="error" role="alert" hidden={!error}>{error?.message}</p>
    </div>
  );
}

Input Aids That Help (Not Hurt)

  • autocomplete="email", name, tel, organization, address-line1, postal-code, country.
  • inputmode="numeric" for digits; pattern only as hint, never to reject legitimate formats you don’t fully support.
  • Mask on blur (e.g., add spaces to card number), not while typing.
  • Password reveal button; show requirements before they fail.
  • Don’t force UPPERCASE input; it harms password entry and names.

Copy That Unblocks

Poor: “Invalid input.”
Better: “Enter a valid email.”
Best: “Enter a valid email, like [email protected].”

Guidelines:

  • Put the action first (“Enter…”, “Select…”, “Choose…”).
  • Avoid blame: don’t say “you did X wrong.”
  • Keep one message per error. If multiple rules fail, show the most helpful first.

International Names, Addresses, and Phones

  • Names can be one word; don’t require first/last.
  • Avoid rigid address schemas; allow address line 2; some countries don’t have ZIP/postcode or state.
  • Phones vary; let users type plus sign and spaces; validate loosely, normalize server-side if needed.
  • Date formats differ; prefer three fields (DD / MM / YYYY) or a locale-aware picker with free text fallback.

Multi-Step Forms & Drafts

  • Persist to localStorage or server after each step.
  • Allow Back without losing data.
  • Show progress (“Step 2 of 4”) and allow resume via magic link.

File Uploads That Don’t Ruin Days

  • Show max size and types up front.
  • Provide progress + retry; chunk uploads if large.
  • Virus-scan server-side and validate MIME; never trust just extensions.
  • If a file fails, keep other fields and selections intact.

Anti‑Patterns to Avoid

  • Disabling Submit until the form is “perfect”. Allow submit, then show errors.
  • Wiping values after an error.
  • Only color to show errors; unreadable toasts.
  • Overeager input masks that fight the user.
  • Requiring account creation to submit a support/contact form.

Testing Checklist (Ship-Ready)

  • [ ] Keyboard only: every control reachable; visible focus; Enter submits.
  • [ ] Screen reader: labels read correctly; errors announced; focus lands on first error.
  • [ ] High-contrast mode and dark mode.
  • [ ] Mobile: correct keyboard for fields; zoom not blocked.
  • [ ] Slow network/offline: helpful messages and retries.
  • [ ] i18n pseudo‑loc: long labels/messages don’t break layout.
  • [ ] Server returns stable error map; client preserves input.
  • [ ] Double‑submit protection (idempotency key or client lock).

Minimal CSS (utility-ish; tweak to your system)

.field { margin-block: 1rem; }
.hint { font-size: 0.9rem; color: var(--muted); }
.error { color: var(--error); margin-top: 0.25rem; }
.error-summary { border: 2px solid var(--error); padding: 1rem; margin-bottom: 1rem; }
[aria-invalid="true"] { border-color: var(--error); }
a { text-decoration: underline; }

What Great Looks Like

  • Conversion lift (fewer abandons), lower support tickets, fewer re-submits.
  • Error messages people can fix without guessing.
  • A single form module your team reuses across apps (shared a11y utilities, error summary, focus helpers, and server error mapping).

Appendix: Focus & Summary Script (Vanilla)

function showErrors(fieldErrors) {
  // fieldErrors: { email: "Enter a valid email", password: "..." }
  const summary = document.getElementById("error-summary");
  const list = summary.querySelector("ul");
  list.innerHTML = "";
  let firstId = null;

  for (const [id, msg] of Object.entries(fieldErrors)) {
    const input = document.getElementById(id);
    const errEl = document.getElementById(id + "-error");
    input.setAttribute("aria-invalid", "true");
    errEl.textContent = msg;
    errEl.hidden = false;
    if (!firstId) firstId = id;

    const li = document.createElement("li");
    const a = document.createElement("a");
    a.href = "#" + id; a.textContent = msg;
    li.appendChild(a); list.appendChild(li);
  }
  summary.hidden = false;
  summary.focus();
  if (firstId) document.getElementById(firstId).focus();
}

Steal these patterns. Your users (and your conversion graph) will thank you.