All articles
Design & UXJune 27, 2026 6 min read

Form Validation Timing: The UX Decision That Breaks Most Sign-up Flows

Validating on every keystroke feels responsive but punishes users mid-thought. Validating only on submit feels rude. Here's the timing model we use to make forms feel calm and competent.

Most sign-up forms aren't broken because of bad copy or ugly inputs. They're broken because the validation fires at the wrong moment — yelling "invalid email" while the user is still typing the @, or staying silent until they hit submit and then dumping six red errors at once. Validation timing is one of the highest-leverage UX decisions in a form, and almost nobody designs it deliberately.

This is the model we use on production forms, what it costs to implement, and the accessibility gotchas that come with it.

The four moments you can validate

There are really only four points where a form field can be checked:

  1. onChange — every keystroke
  2. onBlur — when the user leaves the field
  3. onSubmit — when they try to send the form
  4. async / server — after a network round-trip (uniqueness checks, etc.)

The mistake teams make is picking one globally and applying it everywhere. The right answer is: different fields deserve different timing, and the timing should also change based on whether the user has already made a mistake in that field.

Why "onChange everywhere" feels hostile

Validate-on-keystroke produces a constant stream of errors while the user is mid-thought. They type j, you say "invalid email". They type jo, you say "invalid email". By the time they reach jo@acme.com you've insulted them eight times. Users read this as the form arguing with them.

Why "onSubmit only" feels rude

On the other end, validating only when the user clicks submit produces the classic wall-of-red. They invested 90 seconds filling things out, and now you're telling them four fields are wrong at once. Worse, the cursor is nowhere near the first error, and on mobile they have to scroll back up to find it.

The timing model we actually ship

Here's the rule we apply by default:

Validate on blur the first time. Validate on change once the field is already in an error state. Validate everything on submit as a safety net.

This pattern has a name in some design systems — reward early, punish late — but the mechanic is simple. The first pass is generous: you give the user the benefit of the doubt while they're typing, and only check when they tab away. If they've already made a mistake, you switch to live validation so they can see the moment they've fixed it. That feedback loop is what makes the form feel helpful instead of nagging.

What this looks like in code

With React Hook Form, this is essentially a one-line config:

import { useForm } from 'react-hook-form';

const { register, handleSubmit, formState } = useForm({
  mode: 'onBlur',
  reValidateMode: 'onChange',
  shouldFocusError: true,
});

mode: 'onBlur' handles the first-pass check. reValidateMode: 'onChange' kicks in once a field has an error, so corrections show up immediately. shouldFocusError makes sure the first invalid field is focused on submit — critical for both keyboard and screen reader users.

If you're rolling your own validation, the state machine per field looks like this:

type FieldState = 'pristine' | 'dirty' | 'error' | 'valid';

function nextState(current: FieldState, event: 'change' | 'blur' | 'submit', isValid: boolean): FieldState {
  if (event === 'change' && current === 'error') {
    return isValid ? 'valid' : 'error';
  }
  if (event === 'blur' && current !== 'pristine') {
    return isValid ? 'valid' : 'error';
  }
  if (event === 'submit') {
    return isValid ? 'valid' : 'error';
  }
  return current === 'pristine' ? 'dirty' : current;
}

The key transitions: pristine fields stay quiet, dirty fields check on blur, error fields revalidate on every change. That's the whole pattern.

Exceptions worth knowing

The blur-first rule covers maybe 80% of fields. The other 20% need special handling.

Password creation fields

Password strength is the one place where onChange validation is genuinely helpful — but only because you're showing progress, not errors. A strength meter that fills as the user types is positive feedback. A red error saying "password too weak" on the second character is not. Frame the requirements as a checklist that ticks off, not a list of failures.

Username and email uniqueness

These require a server check, which means debouncing. We use 400–600ms after the last keystroke in our experience, fired only after the field also passes local format validation. Otherwise you're hammering the API with requests for j, jo, joe.

const checkUsername = useDebouncedCallback(async (value: string) => {
  if (!/^[a-z0-9_]{3,}$/i.test(value)) return;
  const res = await fetch(`/api/username-available?u=${value}`);
  const { available } = await res.json();
  setUsernameError(available ? null : 'Already taken');
}, 500);

Also: show a subtle loading indicator during the check. Silence between "I typed something" and "you told me it's taken" reads as a broken form.

Confirm-password and matching fields

Validate on blur of the second field, not the first. The user knows what they typed in the password field; they only care about the match when they've finished retyping it.

The accessibility layer most teams skip

Getting the timing right is half the job. The other half is making the errors announce themselves properly.

  • Error messages need aria-live="polite" on the container, or role="alert" for the message itself, so screen readers pick up the change.
  • Link the error to the input with aria-describedby so the message is read when the field receives focus.
  • Use aria-invalid="true" on the input when it's in an error state — don't rely on color alone.
  • On submit failure, move focus programmatically to the first invalid field. Don't just scroll to it.
<input
  id="email"
  type="email"
  aria-invalid={!!error}
  aria-describedby={error ? 'email-error' : undefined}
  {...register('email')}
/>
{error && (
  <p id="email-error" role="alert" className="text-red-600 text-sm mt-1">
    {error.message}
  </p>
)}

Without these, a sighted keyboard user sees a red border appear and a blind user just hears... nothing. The form silently rejects them.

Motion and the temptation to over-animate

Error states benefit from a tiny amount of motion — but tiny is the operative word. We cap error appearance animations at around 150–200ms with an ease-out curve. The classic shake-the-field animation should be reserved for repeated submit failures, not first-pass blur errors, because it implies "you did something wrong again" rather than "heads up".

If you're using Tailwind, something like:

<div class="transition-colors duration-150 ease-out">

is usually enough. Respect prefers-reduced-motion and drop the transition entirely for users who've opted out — error visibility shouldn't depend on animation playing.

How we measure if it's working

The usual conversion-rate numbers are too noisy at the field level. The metrics we actually watch:

  • Field re-entry rate — how often a user returns to a field after leaving it. High numbers mean either the validation is wrong or the error message is unclear.
  • Submit attempts per successful submission — anything above ~1.3 in our experience suggests the form is rejecting people who should be getting through.
  • Time-to-first-error — if users see their first error within the first three seconds of touching the form, your timing is too aggressive.

None of these are AdSense-grade benchmarks; treat them as direction-finders, not targets.

Where we'd start

If you've inherited a form and you're not sure where to begin: open it, fill it out wrong, fill it out right, and count how many times the form says something to you. If the answer is more than twice per field on the way to a successful submit, your timing is off. Switch the global mode to onBlur with reValidateMode: 'onChange', add aria-live to your error containers, and ship that before you touch anything else. The visual design of the error states can wait — the timing fix alone will recover users you didn't know you were losing.

For a deeper look at the engineering side of design systems that support patterns like this, our services page covers how we approach the Figma-to-component pipeline.

#UX#Forms#Accessibility#Frontend

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project