Submit states, inline errors

Keep submit enabled, show errors inline, and focus the first one.

Web Interface Guidelinesvercel-labs · command.md
Submit button stays enabled until request starts; spinner during request Errors inline next to fields; focus first error on submit
Rules, Forms sectionView source ↗
Web Interface Guidelinesvercel-labs · command.md
Async updates (toasts, validation) need `aria-live="polite"` Error messages include fix/next step, not just problem
Rules, Accessibility and ContentView source ↗

From web-design-guidelines (Web Interface Guidelines).

Sign-up form

Enter a name and ada@company, then submit both.

Disabled until validBad

disabled={!isValid}0 dead clicks
Errors on submitGood

aria-invalid + focus()focus: none
form.addEventListener("submit", (e) => {
  e.preventDefault();
  const errors = validate(form);
  for (const input of form.elements) {
    const msg = errors[input.name];
    input.setAttribute("aria-invalid", String(Boolean(msg)));
    document.getElementById(`${input.id}-err`).textContent = msg ?? "";
  }
  const first = [...form.elements].find((el) => errors[el.name]);
  if (first) return first.focus();
  submit(form);
});

Save, three times fast

Triple-click each Save button.

No request stateBad
    Requests: 0Duplicates: 0
    Pending guardGood

      Requests: 0Clicks ignored: 0
      let pending = false;
      button.addEventListener("click", async () => {
        if (pending) return;
        pending = true;
        button.setAttribute("aria-busy", "true");
        button.setAttribute("aria-disabled", "true");
        label.textContent = "Saving…";
        try { await save(); live.textContent = "Saved."; }
        finally { pending = false; button.removeAttribute("aria-busy"); button.removeAttribute("aria-disabled"); label.textContent = "Save changes"; }
      });

      Error copy that names the fix

      Type in the field and compare the messages.

      States the problemBad

      Screen reader hearsnothing

      <p class="error">
      States the fixGood

      Screen reader hearsnothing yet

      aria-live="polite"
      function passwordError(value) {
        const missing = 8 - value.length;
        if (missing > 0) return `Password needs 8+ characters. Add ${missing} more.`;
        if (!/\d/.test(value)) return "Add a number, like 7, to finish.";
        return "";
      }

      Why it matters, pros and cons

      Why it matters

      A disabled button says no without saying why; an enabled one names the field.

      Pros

      • Every click gets an error, spinner or result.
      • Focus lands on the field to fix.
      • A pending guard stops duplicate writes.

      Cons

      • A client guard is not idempotency; servers need request keys.
      • Never move focus while someone is typing.
      • Under ~300 ms, delay the spinner or it flickers.