VibeWeek
Home/Grow/Form Validation UX: Tell Users What's Wrong Without Making Them Hate You

Form Validation UX: Tell Users What's Wrong Without Making Them Hate You

⬅️ Day 6: Grow Overview

If you ship forms in your SaaS — sign-up, payment, profile, settings, anything with inputs — the validation UX makes the difference between 80% form-completion rate and 40%. Most indie SaaS gets it wrong in the same predictable ways: errors only appear on submit (frustrating); errors marked in red without explanation (vague); password rules sprung on you mid-typing (rude); required fields not marked (guessing). The fix isn't more validation — it's better-timed, better-worded, more accessible validation. Done well, users barely notice form-filling. Done badly, they bounce on the email field.

A working form validation UX answers: when to validate (on blur, not on every keystroke), what messages to show (specific + actionable), how to display errors (where + with what visual treatment), how to handle async validation (uniqueness, server-side checks), how to handle accessibility (ARIA + screen-reader), how to handle progressive disclosure (don't show all rules upfront), and how to measure (form abandon rate by field).

This guide is the implementation playbook for form-validation UX. Companion to Schema Validation with Zod (which covers data-side validation), Onboarding Tour Implementation, Activation Funnel, and Customer Feedback Surveys.

Why Form UX Matters

Get the failure modes clear first.

Help me understand form-validation UX failures.

The 10 failure modes:

**1. On-keystroke validation while typing**
"Password must be 8 characters" appears at character 1.
"Email is invalid" appears at character 5.
Anxiety-inducing; rude.

**2. On-submit only validation**
Fill 10 fields; submit; 5 errors shown at once.
Frustrating; users abandon at the second pass.

**3. Vague error messages**
"Invalid input" tells the user nothing. What's wrong? How to fix?

**4. Red text without context**
Visual marker without explanation; users squint.

**5. Errors away from the field**
Error at top of form; user must scroll to find which field.

**6. Required fields not marked**
Users guess what's required. Asterisk * convention helps.

**7. Async validation timing**
"Email already taken" appears 2 seconds after typing — by then user has moved on.

**8. Password rules sprung mid-typing**
"Must include special character" appears on character 8 of password.
User retypes 3 times to satisfy rules they didn't know upfront.

**9. No success state**
Field accepted but user can't tell.

**10. Inaccessible errors**
Screen readers don't announce errors; visually-impaired users stuck.

For my forms:
- Audit current behavior
- Top complaints / abandon rates
- Compliance (WCAG)

Output:
1. Top 3 failure modes
2. Risk to conversion
3. Priorities

The biggest unforced error: on-keystroke validation while typing. User typing email at "u@" gets red error "invalid email format." User feels harassed; abandons. Validate on blur (focus-leave), not on every keystroke.

When to Validate (the Timing Rules)

Help me get timing right.

The rules:

**Rule 1: Don't validate while user is typing**

Bad: validate on every keystroke
Good: validate on field blur (focus-leave)

```typescript
// React Hook Form / similar pattern
<input 
  onBlur={(e) => validate(e.target.value)}
  onChange={(e) => updateValue(e.target.value)}
/>

User finishes typing email; tabs out; THEN you check format.

Exception: password-strength meter (see below) — show real-time but don't block on it.

Rule 2: Re-validate on change AFTER first blur

Once user has been told "this is invalid," update on every keystroke. Rationale: they're trying to fix it; tell them when they have.

const [hasBlurred, setHasBlurred] = useState(false);
const validate = hasBlurred ? everyKeystroke : onBlur;

Rule 3: On-submit catches everything

Submit button:

  • Run all validations
  • If errors: highlight each; focus on first error; don't submit
  • Show count: "3 errors below"

Rule 4: Async validation on debounced blur

For uniqueness checks (email taken; username available):

  • 300-500ms debounce after blur
  • Show "Checking..." spinner
  • Show ✓ or ✗ when result returns
function UniqueEmailField() {
  const [status, setStatus] = useState<'idle' | 'checking' | 'available' | 'taken'>('idle');
  
  const checkAvailability = useDebouncedCallback(async (email: string) => {
    setStatus('checking');
    const taken = await api.isEmailTaken(email);
    setStatus(taken ? 'taken' : 'available');
  }, 500);
  
  return (
    <div>
      <input onBlur={(e) => checkAvailability(e.target.value)} />
      {status === 'checking' && <Spinner />}
      {status === 'available' && <span className="success">✓ Available</span>}
      {status === 'taken' && <span className="error">✗ Already taken</span>}
    </div>
  );
}

Rule 5: Don't show errors while user is fixing

Once user starts typing again after error, hide the error until they blur again (or revalidate on each keystroke if helpful).

For my forms:

  • Audit timing today
  • Most-frustrating field

Output:

  1. Timing rules per field type
  2. Implementation

The discipline: **on-blur first; on-keystroke after error**. This catches bad input gracefully, then helps the user fix it without re-frustrating them.

## Error Messages: Specific and Actionable

Help me write good error messages.

The principle: tell the user EXACTLY what's wrong AND how to fix.

Bad → Good comparison:

Bad: "Invalid email" Good: "Email must include @ and a domain (e.g., user@example.com)"

Bad: "Field is required" Good: "Please enter your business email"

Bad: "Password too weak" Good: "Password needs 8+ characters and at least one number"

Bad: "Try again" Good: "We couldn't find an account with that email. Sign up?"

Bad: "Invalid format" Good: "Phone number must be 10 digits, including area code"

The formula:

[What's wrong] + [How to fix] + (optional) [Example]

Each error message: 7-15 words. Tone: helpful, not blaming.

Anti-patterns:

  • "Error" / "Invalid" / "Wrong" alone (vague)
  • ALL CAPS (shouting)
  • "Please" + apology language overload (bureaucratic)
  • Technical jargon ("ENOENT") in user-facing
  • Negative framing ("You can't do that") → positive ("Use letters and numbers only")

Severity tiers:

  • Error (red): blocks submit; must fix
  • Warning (yellow): proceed allowed; warns
  • Info (blue): tip; not problematic

Don't make EVERYTHING red. Reserve for blocking issues.

Internationalization:

Pre-translate; don't construct dynamically.

Bad: ${field} is invalid → translates badly Good: distinct messages per field, each translated.

For my messages:

  • Audit current
  • Top 10 worst messages

Output:

  1. Rewrite top 10
  2. Style guide
  3. Translation strategy

The discipline: **read your error messages aloud**. If they sound robotic or accusatory, rewrite. The voice should be a helpful friend, not a system error.

## Error Display: Where and How

Help me display errors.

The placement:

Inline (under the field): Most common; clear association.

<div class="form-field">
  <label for="email">Email</label>
  <input id="email" aria-describedby="email-error" aria-invalid="true" />
  <div id="email-error" role="alert" class="error">
    Email must include @ and a domain
  </div>
</div>

Field-level icon: Visual marker on the input itself.

✗ Email field: red border + ✗ icon ✓ Email field: green border + ✓ icon (after success)

Summary at top: For long forms, summary helps:

"3 errors found. Please fix:
 - Email format
 - Password length
 - Phone number"

Each error item is a link that scrolls to + focuses the field.

Use case:

  • Short forms (3-5 fields): inline only
  • Long forms (10+ fields): inline + summary at top

Visual treatment:

  • Red border on input (outline-2 outline-red-500)
  • Red error text below (text-red-600)
  • ✗ icon (optional) for redundancy with color (accessibility)
  • Subtle shake animation on submit error (optional; not required)

DON'T:

  • Red on a red background (invisible to colorblind)
  • Move the layout when error appears (jumps)
  • Show error as alert / popup (interrupting)

Show progress for multi-step forms:

For long forms, break into steps with progress indicator. Each step: "Step 2 of 4: Account Details"

For my forms: [audit]

Output:

  1. Display pattern
  2. Visual treatment
  3. Layout consideration

The accessibility-must: **don't rely on color alone**. WCAG 1.4.1 requires non-color indication of error. Add ✗ icon or text label "Error:" prefix.

## Required Fields and Optionality

Help me mark required fields.

The convention:

Required fields: marked with red asterisk Optional fields: marked with "(optional)"

Pick one approach for the form; don't mix.

<label for="email">Email <span class="required">*</span></label>
<input id="email" required />

OR

<label for="phone">Phone <span class="optional">(optional)</span></label>
<input id="phone" />

The 2026 trend: mark optional, not required. Most fields are required by default; "(optional)" is the exception.

Reduces visual noise; matches Material Design / iOS conventions.

The "what counts as required" question:

  • Sign-up: email, password (just the basics)
  • Profile: only what's needed for account function
  • Payment: only what payment processor requires
  • Marketing forms: as few as possible (1-2 fields max)

The principle: fewer fields = higher conversion. Each required field = 5-15% conversion drop typically. Justify each.

Default values + smart pre-fills:

  • Country: detect from IP; pre-fill (user can change)
  • Currency: detect; pre-fill
  • Language: from browser locale
  • Timezone: from browser

Reduces typing; respects user's context.

Auto-complete attributes:

<input type="email" autocomplete="email" />
<input type="text" autocomplete="given-name" />
<input type="tel" autocomplete="tel" />
<input type="text" autocomplete="postal-code" />

Browsers / password managers fill these in faster.

For my forms: [audit]

Output:

  1. Required-vs-optional convention
  2. Field-reduction audit
  3. Auto-complete additions

The single fastest conversion win: **remove unnecessary fields**. "Phone number" in sign-up that nobody calls? Cut it. "Company size" before they're a customer? Cut it. Re-evaluate every field.

## Special Patterns: Passwords, Email, Phone

Help me handle special fields.

Password fields:

Display rules upfront:

Password
[input]
Show password [eye-icon-toggle]

Requirements:
✓ 8+ characters (currently 6)
✓ One number
✗ One special character

Each requirement turns green as user types. Don't BLOCK submit until met; SHOW status.

Strength meter:

  • Weak / Medium / Strong indicator
  • Color: red / yellow / green
  • Suggest improvements ("Add a special character for stronger")

Don't:

  • Hide rules until they fail
  • Require excessive complexity (15+ chars, 4 special chars = abandonment)
  • Disable paste (password managers need paste)

In 2026:

  • Allow long passwords (encourage passphrase)
  • Don't enforce arbitrary character classes (NIST guidelines)
  • Check against breached-password lists (Have I Been Pwned)
  • Offer passkeys as alternative

Email fields:

  • type="email" (mobile keyboard)
  • autocomplete="email"
  • Validate format on blur (RFC 5322; not too strict)
  • Lower-case on submit

Validation regex:

const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

Don't try to fully implement RFC 5322 — too complex; backend re-validates anyway.

Common typos: gmial.com → suggest "gmail.com?"

const TYPO_FIXES = {
  'gmial.com': 'gmail.com',
  'gnail.com': 'gmail.com',
  'gmial.con': 'gmail.com',
  'yhaoo.com': 'yahoo.com',
  // ... 50 most common
};

function suggestEmail(email: string): string | null {
  const domain = email.split('@')[1]?.toLowerCase();
  const fix = TYPO_FIXES[domain];
  return fix ? email.replace(domain, fix) : null;
}

Show: "Did you mean user@gmail.com? [Use this]"

Phone fields:

  • type="tel" (mobile keyboard)
  • autocomplete="tel"
  • Format as user types (libphonenumber-js)
  • Country code with flag picker
  • Validate on blur with libphonenumber
import { parsePhoneNumber, isValidNumber } from 'libphonenumber-js';

const phone = parsePhoneNumber(input, 'US');
const valid = isValidNumber(input, 'US');
const formatted = phone?.formatNational(); // (555) 123-4567

Credit card fields:

  • Use Stripe Elements / similar (don't roll your own; PCI scope)
  • Real-time card-brand detection (Visa / MC / Amex)
  • Format with spaces: "4111 1111 1111 1111"
  • Expire date: MM/YY format
  • Auto-advance focus (after 4 digits, jump to next group)

For my forms: [special fields]

Output:

  1. Per-field-type pattern
  2. Library picks
  3. Edge case handling

The win that compounds: **email-typo suggestions**. Catches 5-10% of typo'd emails before they cause "I never got your verification" support tickets. Tiny code; huge UX win.

## Async Validation: Uniqueness, Server-Side Checks

Help me handle async validation.

The pattern:

function UsernameField() {
  const [value, setValue] = useState('');
  const [status, setStatus] = useState<Status>('idle');
  
  const check = useDebouncedCallback(async (val: string) => {
    if (val.length < 3) { setStatus('idle'); return; }
    if (!val.match(/^[a-z0-9_]+$/)) { 
      setStatus('format-error'); 
      return; 
    }
    
    setStatus('checking');
    const taken = await api.isUsernameTaken(val);
    setStatus(taken ? 'taken' : 'available');
  }, 500);
  
  return (
    <div>
      <input
        value={value}
        onChange={(e) => {
          setValue(e.target.value);
          check(e.target.value);
        }}
      />
      
      {status === 'idle' && <Hint>3+ chars; letters / numbers / _</Hint>}
      {status === 'format-error' && <Error>Use letters, numbers, and _ only</Error>}
      {status === 'checking' && <Spinner />}
      {status === 'available' && <Success>✓ {value} is available!</Success>}
      {status === 'taken' && <Error>{value} is taken — try another?</Error>}
    </div>
  );
}

Patterns:

  • Debounce 300-500ms (not too fast; respects server)
  • Show "Checking..." state (don't leave blank)
  • Cancel previous requests on new input
  • Cache results (don't re-check same value)
  • Race-condition handling: if user types fast, only show last response

Anti-patterns:

  • Sync API call on every keystroke (server-side overload)
  • No debounce (1 character = 1 API call)
  • No "Checking..." state (looks broken)
  • Showing "✗ Taken" when network is just slow

Race condition:

const requestId = ++currentRequestId.current;
const result = await api.check(value);
if (requestId !== currentRequestId.current) return; // Newer request fired
setStatus(result.taken ? 'taken' : 'available');

Or use AbortController to cancel old fetches.

For my async fields: [list]

Output:

  1. Debounce pattern
  2. Cache strategy
  3. Race-condition guard

The non-obvious detail: **cache results within the session**. User types "cool-username"; checks taken; backspaces; re-types same; you re-check same value. Cache prevents this.

## Accessibility: Don't Lock Out Screen Readers

Help me make forms accessible.

The 5 essentials:

1. Label every input

<label for="email">Email</label>
<input id="email" type="email" />

OR aria-label:

<input type="email" aria-label="Email" />

Never have unlabeled inputs.

2. Mark errors with aria-invalid + aria-describedby

<input
  id="email"
  type="email"
  aria-invalid={hasError}
  aria-describedby={hasError ? 'email-error' : undefined}
/>
{hasError && <div id="email-error" role="alert">Email format invalid</div>}

role="alert" makes screen readers announce on appearance.

3. Required attribute + aria-required

<input type="email" required aria-required="true" />

Screen readers announce "Email, required."

4. Group related fields with fieldset / legend

<fieldset>
  <legend>Shipping address</legend>
  <label for="street">Street</label>
  <input id="street" />
  ...
</fieldset>

Screen reader announces the group context.

5. Focus on first error after submit

const onSubmit = (data) => {
  const errors = validate(data);
  if (errors.length > 0) {
    document.getElementById(errors[0].fieldId)?.focus();
    return;
  }
  // ...
};

Don't make user tab through 10 fields to find the broken one.

The keyboard-only test:

Tab through your form. Can you:

  • See the focused field clearly (visible focus ring)?
  • Submit with Enter?
  • Cancel with Escape?
  • Reach all fields without mouse?

If no: fix.

Color contrast:

Errors must be 4.5:1 contrast against background (WCAG AA). Light red on white often fails. Use red-600 or darker.

For my forms: [audit]

Output:

  1. ARIA additions
  2. Keyboard test
  3. Color audit

The compliance reality: **WCAG 2.1 AA is the legal floor in EU (EAA from 2025) and many US contexts**. Inaccessible forms = lawsuit risk + lost market. Accessibility is not optional.

## Measuring Form UX

Help me measure.

The KPIs:

Form-level:

  • Form-completion rate (% who submit / % who started)
  • Time-to-complete (median)
  • Abandon point (which field they give up at)
  • Submit-with-errors rate (per submission)

Field-level:

  • Time-spent per field (median)
  • Error rate per field
  • Re-edit rate (corrections per field)

Tools:

  • PostHog Session Replay — watch users fill forms
  • FullStory / Hotjar — heatmaps + replays
  • Custom event tracking: form_field_focus, form_field_blur, form_field_error, form_submit
analytics.track('form_field_blurred', {
  form: 'signup',
  field: 'email',
  hadError: hasError,
  timeSpentMs: Date.now() - focusedAt,
});

Targets:

  • Sign-up form completion: 60-85%
  • Checkout form: 70-90%
  • Profile / settings: 80-95%

Below target: investigate which field is dropping users.

Quarterly review:

Watch 10 session replays of users abandoning. What confuses them?

For my forms:

  • Top abandoned form
  • Worst field

Output:

  1. KPIs
  2. Tooling
  3. Review cadence

The cheapest highest-leverage UX investment: **session replay on sign-up + checkout**. PostHog free tier covers 1M events; you'll see exactly where users get stuck. Fix iteratively.

## Common Form-UX Mistakes

Help me avoid mistakes.

The 10 mistakes:

1. On-keystroke validation while typing Frustrates users; abandons.

2. Vague errors ("invalid input") Tell user what's wrong + how to fix.

3. All-CAPS error text Shouting; rude.

4. Required asterisks without legend Users wonder what * means.

5. Hidden password requirements Sprung mid-typing; users retype 5 times.

6. No async validation feedback ("Checking...") Looks broken when latency is high.

7. Errors in popup / alert Interrupting; loses context.

8. Unaccessible (no aria-invalid; no role=alert) Screen readers can't announce errors.

9. No focus on first error after submit User tabs through to find issue.

10. 30-field forms with no progressive disclosure Overwhelming; abandonment.

For my forms: [risks]

Output:

  1. Top 3 risks
  2. Mitigations
  3. Audit checklist

The single highest-leverage form-UX fix: **on-blur validation + specific error messages**. Two changes; recovers 5-15% conversion typically. The conversion math always justifies the engineering time.

## What Done Looks Like

A working form-UX delivers:
- On-blur validation (not on-keystroke)
- Re-validate on change after first error
- Specific actionable error messages (what + how to fix)
- Errors inline under field with aria-invalid + role=alert
- Required vs optional clearly marked (one convention)
- Auto-complete attributes on standard fields
- Async validation with debounce + "checking" state
- Email typo suggestions
- Password requirements visible upfront with live status
- Focus on first error after submit
- Session replay watching abandoned sessions
- Form-completion rate ≥ industry average

The proof you got it right: a screen-reader user can complete your sign-up; a user with poor connection sees clear "checking" / "available" states; a user who mistypes "@gmial" gets a "did you mean" suggestion. Form completion rate climbs.

## See Also

- [Schema Validation with Zod](schema-validation-zod-chat.md) — companion data-side validation
- [Onboarding Tour Implementation](onboarding-tour-implementation-chat.md) — companion onboarding UX
- [Activation Funnel](activation-funnel-chat.md) — forms are activation surface
- [Customer Feedback Surveys](customer-feedback-surveys-chat.md) — survey forms apply same UX
- [Internationalization](internationalization-chat.md) — i18n form messages
- [CAPTCHA & Bot Protection](captcha-bot-protection-chat.md) — companion sign-up protection
- [Email Deliverability](email-deliverability-chat.md) — email validation pairs
- [Two-Factor Auth](two-factor-auth-chat.md) — verification forms
- [Password Reset & Magic Link](password-reset-magic-link-chat.md) — reset forms
- [VibeReference: Form Builders](https://vibereference.dev/frontend/form-builders) — builder tools
- [VibeReference: Accessibility](https://vibereference.dev/product-and-design/accessibility) — broader a11y context
- [VibeReference: Email Verification & List-Hygiene Tools](https://vibereference.dev/marketing-and-seo/email-verification-validation-tools) — async validation backends