splitforms.com
← Back to the journal

Validate Form Fields on Blur (Not on Every Keystroke)

Show inline form errors on blur, clear them on input when valid, and re-check on submit. Covers :user-invalid, aria-invalid, vanilla JS and React.

Start free — 500 submissionsSee pricing →No card, no time limit. Pro is $5 per month.
Short answer
Show inline errors on blur, clear them on input as soon as the field is valid again, and run a full pass on submit. Style with :user-invalid (not bare :invalid) or a .touched class, set aria-invalid and wire aria-describedby to your error paragraph.
Try it on your own form

Point any HTML form at one endpoint. 500 free submissions — no card, no time limit — and unlimited forms.

Create free account

The problem: errors while the user is still typing

If you validate on every input event, the email field turns red after j because j is not a valid address. The user sees failure before they finish, learns to ignore red borders, and sometimes stops typing altogether. Submit-only validation avoids that annoyance but hides every mistake until the end, when fixing five fields at once feels like homework.

The middle path is interaction-aware timing: wait until the user leaves a field (blur) before you show its first error, then reward fixes immediately on the next input. That pairs well with native constraints (required, type="email", pattern) and with custom copy from changing the HTML5 validation message.

When to show errors: input, blur, or submit

TimingProsCons
input (every keystroke)Instant positive feedback once the value becomes valid; good for password strength metersErrors appear mid-word; noisy for email, names, and long text
blur (leave the field)User finished one field; one error at a time; matches mental modelSkips untouched fields until submit; blur also fires when focus moves to buttons
submitSingle list of everything wrong; simple to implementLate feedback; user may not know which fields they never visited

Most production contact forms combine blur plus submit: blur for inline text, submit to catch skipped boxes and focus the first failure. The full Constraint Validation API tour lives in how to validate an HTML form with JavaScript.

Reward early, punish late

Treat validation as two different moments. Punish late: on blur, if the control is invalid, set aria-invalid="true" and show the message. Reward early: on input, as soon as el.checkValidity() passes, clear the message and set aria-invalid="false" even if the user has not blurred again. They fixed it — do not keep the old error visible until blur.

CSS-only styling with :user-invalid

Plain input:invalid matches empty required fields on first paint, so the form looks broken before interaction. Prefer :user-invalid and :user-valid, which wait until the user has changed the control:

<label for="email">Email</label>
<input id="email" name="email" type="email" required
  aria-describedby="email-error" />
<p id="email-error" role="alert"></p>

<style>
  input:user-valid { outline: 2px solid #059669; }
  input:user-invalid { outline: 2px solid #dc2626; }
</style>

Support is strong in Chromium and Firefox; Safari added :user-invalid in recent releases. Where it is missing, add a .touched class on blur and mirror the selector:

input.touched:user-invalid,
input.touched:invalid {
  outline: 2px solid #dc2626;
}

CSS can hint with color but cannot write accessible error text — pair pseudo-classes with the JavaScript pattern below for messages and ARIA.

Vanilla JavaScript: blur, input, and submit

Keep one error paragraph per field, linked with aria-describedby. On blur, call checkValidity(), mirror validationMessage, and toggle aria-invalid. On input, clear when valid. On submit, block if anything still fails and focus the first invalid control:

<form id="lead" action="https://splitforms.com/api/submit" method="post">
  <input type="hidden" name="access_key" value="YOUR_KEY" />

  <label for="email">Email</label>
  <input id="email" name="email" type="email" required
    aria-describedby="email-error" />
  <p id="email-error" role="alert"></p>

  <button type="submit">Send</button>
</form>

<script>
  const form = document.getElementById("lead");

  function showError(el) {
    const err = document.getElementById(el.id + "-error");
    if (!el.checkValidity()) {
      el.setAttribute("aria-invalid", "true");
      el.classList.add("touched");
      if (err) err.textContent = el.validationMessage;
      return false;
    }
    el.setAttribute("aria-invalid", "false");
    if (err) err.textContent = "";
    return true;
  }

  form.querySelectorAll("input, select, textarea").forEach((el) => {
    el.addEventListener("blur", () => {
      if (!el.required && el.value === "") return;
      showError(el);
    });
    el.addEventListener("input", () => {
      if (el.checkValidity()) showError(el);
    });
  });

  form.addEventListener("submit", (e) => {
    let firstBad = null;
    for (const el of form.querySelectorAll("input, select, textarea")) {
      if (!showError(el)) firstBad ??= el;
    }
    if (firstBad) {
      e.preventDefault();
      firstBad.focus();
    }
  });
</script>

This uses the browser's built-in messages (customize them with setCustomValidity as in the custom HTML5 guide). For disabling the button until the whole form passes — a separate UX choice — see disable the submit button until the form is valid.

React: touched state and onBlur

function Field({ name, label, ...props }) {
  const [touched, setTouched] = useState(false);
  const [error, setError] = useState("");
  const id = useId();
  const errorId = id + "-error";

  return (
    <>
      <label htmlFor={id}>{label}</label>
      <input
        id={id}
        name={name}
        aria-invalid={error ? "true" : undefined}
        aria-describedby={error ? errorId : undefined}
        onBlur={(e) => {
          setTouched(true);
          const el = e.currentTarget;
          setError(el.checkValidity() ? "" : el.validationMessage);
        }}
        onChange={(e) => {
          const el = e.currentTarget;
          if (touched && el.checkValidity()) setError("");
        }}
        {...props}
      />
      {touched && error ? (
        <p id={errorId} role="alert">{error}</p>
      ) : null}
    </>
  );
}

On form submit, set touched on every field (or call reportValidity() once) so untouched invalid controls surface. Libraries like React Hook Form expose mode: "onBlur" for the same timing without reimplementing the flags.

Common mistakes

  • Blur when clicking Cancel: focus leaves the field before the click handler runs. Use a dirty flag or relatedTarget checks so you do not scold an empty field the user never edited.
  • Autofill: saved values may not fire input. Listen for change and run a silent validity pass on load if you disable submit until valid.
  • Optional fields: do not run pattern checks on blur when value === '' and the field is not required.
  • Using :invalid alone: required empty inputs look invalid on page load. Use :user-invalid or .touched.

Send the valid form to splitforms

Client-side blur validation is UX, not security. When every field passes, POST to your endpoint — for example https://splitforms.com/api/submit with your public access key — or paste markup into the form validator. Validate again on the server. Sign up free for a splitforms endpoint that stores submissions and emails you when the form is valid.

FAQ

Should I show validation errors on input, blur, or submit?

Blur is the best default for inline text: the user finishes a thought before you criticize it. Validate on submit for anything they skipped, and use input only to clear an error once the field becomes valid again. Validating on every keystroke while they type an email address usually feels hostile.

What is the difference between :invalid and :user-invalid?

:invalid matches as soon as the value fails constraints, even on page load, so empty required fields look broken before anyone types. :user-invalid (and :user-valid) apply only after the user has interacted with the control, which is why they are safer for red borders without JavaScript.

How do I validate on blur in React without showing errors too early?

Track touched state per field. Run your check in onBlur (or when touched becomes true), set aria-invalid and the error message, and on onChange clear the message as soon as the value passes. On submit, mark every field touched (or run a full pass) and focus the first failure.

Why did blur validation fire when I clicked Cancel?

Blur fires whenever focus leaves the field, including when the user tabs to a button or clicks elsewhere. If Cancel sits before the field in tab order, clicking it blurs the field first. Validate on blur only after a real edit (dirty flag), or use pointerdown on Cancel with preventDefault on mousedown so focus does not leave mid-edit.

Should optional empty fields show an error on blur?

No. An optional field with no value should pass valueMissing checks. Only run pattern, minlength, or type checks when the user entered something. In JavaScript, skip blur validation when el.value === '' and !el.required.

References: MDN: :user-invalid and MDN: aria-invalid.

Related articles

More practical guidance from tutorials.

Browse the journal →
Tutorials

How to Remove the Arrows From a Number Input (CSS, Tailwind, React)

Hide the number input spin buttons with ::-webkit-inner-spin-button and appearance: textfiel…

7 min readRead →
Tutorials

How to Change the Autofill Background Color of an Input (CSS, Tailwind)

Remove Chrome's yellow autofill background with input:-webkit-autofill, inset box-shadow, -w…

7 min readRead →
Tutorials

input type=number maxlength Not Working? 4 Fixes That Work

Why maxlength is ignored on input type=number, plus four fixes: min/max, oninput slice, text…

8 min readRead →

Explore this topic

Start with the overview, then move into focused guides.

OverviewContact Forms for Every FrameworkStart here →GuideContact Form for Next.jsRead →GuideContact Form for ReactRead →GuideContact Form for VueRead →ReferenceContact form guideOpen →

Building forms with ChatGPT, Claude, Cursor, or v0? Connect the native MCP server and give your agent a production form backend.

Explore the MCP server →

Give your form a production backend.

One endpoint adds delivery, spam filtering, storage, and integrations. Start with 500 free submissions — no card, no time limit.

Create free accountRead the docs →
Secure checkoutSSL encryptionPrivacyProtected
VISAAMERICANEXPRESSstripe