All articles
Design & UXAugust 17, 2026 6 min read

Form Errors Should Move Toward the User, Not Away: Inline Validation That Actually Helps

Most inline validation is punishment dressed up as UX. Here's how to design and build form errors that reduce abandonment instead of causing it.

Most form validation is designed as if the user is trying to cheat the system. Fields go red the moment they lose focus, error text screams from below the input, and submit buttons stay disabled with no explanation. It's a UX pattern shaped by developer convenience, not by anyone who has watched a real person fill out a form.

This piece is a practical breakdown of how to design and build inline validation that actually reduces abandonment — the timing, the copy, the accessibility wiring, and the code shape we use on client work.

The core problem: validation timing is a UX decision, not a technical one

There are four moments where you can validate a field:

  1. On every keystroke (onChange)
  2. When the field loses focus (onBlur)
  3. On submit
  4. After the user has already made an error once, then on every keystroke until it's fixed

Almost every bad form on the internet uses option 1 or 2 by default for every field. Option 4 is what you actually want most of the time, and it has a name in the research literature: reward early, punish late.

The rule we use on client projects:

Validate as strictly as possible on submit. Validate as gently as possible before submit. Once a field has failed, switch it to live validation so the user sees their fix land in real time.

That one sentence eliminates about 80% of the friction you see on typical checkout and signup flows.

Why on-blur validation feels hostile

On-blur validation punishes the user for the natural act of tabbing away or clicking elsewhere. It's especially bad on email fields, where the user often blurs mid-thought — they typed jane@gmail and were about to add .com when they alt-tabbed to check their inbox. Coming back to a red error is a small emotional slap for no reason.

On-blur is fine after a submit attempt has already surfaced the error. Before that, it's premature.

Error copy: describe the fix, not the failure

Most error messages are written from the system's point of view:

  • "Invalid email address"
  • "Password does not meet requirements"
  • "Field required"

Rewrite them from the user's point of view — what do they need to do?

  • "Add the part after the @, like example.com"
  • "Add one number or symbol to make this password stronger"
  • "We need your billing ZIP to match your card"

Three rules of thumb:

  • Name the fix, not the rule. "Must contain 8+ characters, one number, one symbol" is a spec. "Add 3 more characters" is a fix.
  • Never blame the user. "You entered an invalid..." is worse than "This doesn't look like a valid...".
  • Localize the requirement. If a ZIP must match a card, say so. If a username is taken, offer alternatives.

We've seen conversion improvements in the low single digits on signup flows just from rewriting error copy, with zero engineering changes. It's the cheapest UX win in your backlog.

Where the error lives matters

Three common placements, ranked worst to best for most cases:

  1. Toast at the top of the page after submit. The user has to scroll, scan, and match toast to field. Terrible on mobile.
  2. Summary block at the top of the form. Better for screen readers if implemented well, but sighted users still have to hunt.
  3. Inline, directly below or beside the field, with the field visually marked.

Option 3 is the default, but with one addition: on submit, if there are multiple errors, scroll and focus the first invalid field. Don't just paint the errors and hope the user finds them.

const onSubmit = handleSubmit(
  async (data) => { /* happy path */ },
  (errors) => {
    const firstError = Object.keys(errors)[0];
    const el = document.querySelector<HTMLElement>(
      `[name="${firstError}"]`
    );
    el?.focus();
    el?.scrollIntoView({ block: 'center', behavior: 'smooth' });
  }
);

Accessibility: the parts most teams skip

Inline validation is one of the areas where accessibility and general UX align almost perfectly. If a screen reader user can't tell that an error appeared, a sighted user glancing at their phone probably can't either.

The minimum viable wiring for an accessible error field:

<label htmlFor="email">Email</label>
<input
  id="email"
  name="email"
  type="email"
  autoComplete="email"
  aria-invalid={!!error}
  aria-describedby={error ? 'email-error' : undefined}
/>
{error && (
  <p id="email-error" role="alert" className="text-sm text-red-700">
    {error}
  </p>
)}

A few things worth calling out:

  • aria-invalid gets read as "invalid entry" by most screen readers.
  • aria-describedby links the field to its error so the error is announced when focus lands on the field again.
  • role="alert" announces the error the moment it appears in the DOM. Use it only for real errors, not for helper text.
  • Don't rely on color alone. A red border and red text is invisible to a chunk of users. Add an icon or a short text prefix like "Error:".

Contrast is not optional

Red on white often fails WCAG AA at small text sizes. Our default red for error text is closer to #B00020 than #FF3B30. Run your error color against your input background at 14px through any contrast checker before shipping.

A concrete pattern with React Hook Form

Here's the shape we use on most client projects. It implements the reward early, punish late rule with almost no ceremony.

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const schema = z.object({
  email: z.string().email('Add the part after @, like example.com'),
  password: z
    .string()
    .min(8, 'Add a few more characters — 8 minimum')
});

export function SignupForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, submitCount }
  } = useForm({
    resolver: zodResolver(schema),
    mode: 'onSubmit',
    reValidateMode: 'onChange'
  });

  const shouldShow = (field: keyof typeof errors) =>
    submitCount > 0 && errors[field];

  return (
    <form onSubmit={handleSubmit(onValid)} noValidate>
      <Field
        label="Email"
        error={shouldShow('email') ? errors.email?.message : undefined}
        {...register('email')}
      />
      <Field
        label="Password"
        type="password"
        error={shouldShow('password') ? errors.password?.message : undefined}
        {...register('password')}
      />
      <button type="submit">Create account</button>
    </form>
  );
}

The key configuration is mode: 'onSubmit' combined with reValidateMode: 'onChange'. The user gets no errors until they submit. After they submit, any invalid field validates on every keystroke so they see their fix land. Fields that were valid on submit stay quiet.

noValidate on the form disables the browser's native error bubbles, which are ugly, inconsistent across browsers, and often inaccessible.

Special cases worth handling explicitly

A few field types deserve their own rules because the generic pattern hurts them.

Passwords

Passwords are the one field where live feedback on keystroke is genuinely useful — but as progress, not as error. Show requirements as a checklist that ticks off as the user types. Never turn the field red while they're actively typing their first attempt.

Async validation (username taken, email exists)

Debounce to about 400–600ms after typing stops. Show a subtle loading indicator inside the field. Never validate on every keystroke against your API — it's expensive and it flickers.

Credit card numbers

Validate the Luhn checksum on blur, not on keystroke. Auto-format with spaces as the user types. Detect card brand and show the logo — it's a subtle confirmation that the system is reading what they typed correctly.

Confirm password

Only validate the confirm field after the user has interacted with it and after they leave it or submit. Comparing on every keystroke while they're still typing is guaranteed to flash red for a full second.

Where we'd start

If you're inheriting a form-heavy product and want the highest-leverage fixes first:

  1. Audit your validation timing. Change everything from onBlur or onChange to submit-first, then live on error. This is usually a one-line config change.
  2. Rewrite the five worst error messages into instructions instead of accusations. Start with signup, checkout, and payment.
  3. Wire aria-invalid, aria-describedby, and role="alert" into your shared <Field> component so every form gets it for free.
  4. Add scroll-and-focus on submit failure. One function, huge impact on long forms.
  5. Check your error color contrast at your actual body text size, not at heading size.

None of this needs a redesign or a new library. It's a week of focused work on a shared form component, and it will move your submission rates more than most feature launches. If you want a hand shaping the shared component library that underpins this, that's the kind of work we do on our product engineering engagements.

#forms#accessibility#ux patterns#react#conversion

Want a team like ours?

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

Start a project