Skip to main content

✅ HTML5 Form Validation

A form is a conversation between your app and a stranger — and strangers make mistakes, and sometimes mischief. Validation is how you catch both. In this lesson you'll use the browser's built-in validation to guide honest users instantly, then reach for the JavaScript Constraint Validation API when the rules get interesting — all while remembering the golden rule: the client can help, but only the server can be trusted.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain why client-side and server-side validation serve different purposes and why you always need both
  • Apply the core HTML5 validation attributes — required, type, pattern, min/max, minlength/maxlength, step
  • Style validity states safely with the :valid, :invalid, :user-invalid and related pseudo-classes
  • Use the Constraint Validation API (validity, setCustomValidity(), reportValidity()) to build custom rules and messages
  • Make validation accessible with aria-invalid, aria-describedby, and live error regions

Estimated Time: 40–55 minutes  •  Difficulty: Intermediate

Hands-on: Build a self-validating sign-up form with a live-matching password confirmation field.

In This Lesson

Why Validate?

Form validation is the process of checking that user input meets your expectations before you act on it. Good validation improves data quality, protects your backend from garbage and attacks, and — done well — makes the form feel helpful rather than hostile.

💡 An analogy: Think of validation as the checkpoint at an airport. The friendly agent at the desk who spots your boarding pass is in the wrong terminal (client-side) saves you a long walk. But the security scanner that nobody can talk their way past (server-side) is what actually keeps the plane safe. You want both, and you'd never remove the scanner just because the desk agent is nicer.

Every valid submission that reaches your server is data you're about to store, email, charge a card against, or show to other users. If it's malformed, everything downstream inherits the mess. Validation is the earliest, cheapest place to stop that.

flowchart TD A[User fills field] --> B{Constraints met?} B -->|Yes| C[Allow submit] B -->|No| D[Show inline message] D --> A C --> E[Server re-validates] E -->|Valid| F[Process & store] E -->|Invalid| G[Reject with 400]

Client-Side vs. Server-Side

These are not competitors — they're a team with different jobs. Client-side validation is about experience; server-side validation is about trust.

Client-Side (browser) Server-Side (your backend)
Runs instantly as the user typesRuns after the request arrives
Great UX, immediate feedbackSlower, needs a round trip
Can be bypassed (DevTools, curl, disabled JS)Cannot be bypassed — you control it
A convenienceA security necessity

⚠️ Never trust the client

Anyone can open the network tab, copy your request, strip the validation, and send whatever they like straight to your endpoint. Treat every byte that reaches the server as hostile until the server itself has checked it. Client-side validation exists to be helpful, not to be a security control.

The HTML5 Validation Attributes

Before HTML5, checking that a field was filled in meant writing JavaScript. Now the browser does the common cases for you — accessibly, in the user's language, for free. You opt in with attributes.

required

The field must have a value before the form can submit.

<label for="name">Name</label>
<input type="text" id="name" name="name" required>

Type-based validation

Several input types carry their own rules. The browser checks the format and shows a localized error automatically.

<input type="email" name="email" required>   <!-- must look like an address -->
<input type="url"   name="site">             <!-- must be a valid URL -->
<input type="number" name="qty">            <!-- must be numeric -->

minlength and maxlength

Bound the length of text input.

<label for="username">Username (4–20 characters)</label>
<input type="text" id="username" name="username"
       minlength="4" maxlength="20" required>

min, max, and step

Bound the range and granularity of numeric, date, and time inputs.

<label for="age">Age (18–100)</label>
<input type="number" id="age" name="age"
       min="18" max="100" step="1">

pattern

When you need a format HTML doesn't know about, supply a regular expression. The pattern is implicitly anchored to the whole value (as if wrapped in ^(?:…)$). Always pair it with a title that explains the format in plain words.

<label for="zip">US ZIP code</label>
<input type="text" id="zip" name="zip"
       pattern="[0-9]{5}(-[0-9]{4})?"
       title="Five digits, optionally followed by a dash and four more">

📖 Key Terms

Constraint: a single rule an input must satisfy (e.g. "at least 8 characters").

Valid: a field satisfies all of its constraints.

Validity state: the specific reason a field is invalid, exposed by the browser (e.g. valueMissing, patternMismatch).

Styling Validity States

The browser exposes CSS pseudo-classes that reflect a field's live validity, so you can color a border green or red without a line of JavaScript.

Pseudo-classMatches when…
:validthe field meets all constraints
:invalidthe field fails any constraint
:required / :optionalthe field is / isn't required
:in-range / :out-of-rangea number is within / outside min–max
:user-invalidthe field is invalid and the user has interacted with it

⚠️ The premature-red problem

Styling bare :invalid paints every required field red the instant the page loads — before the user has typed anything. That's discouraging and looks broken. Only flag a field after the user has engaged with it.

The modern, one-line fix is the :user-invalid pseudo-class (supported across current browsers). It only matches once the user has edited the field and moved on:

input:user-invalid {
  border-color: var(--danger, #dc2626);
  background-color: #fff5f5;
}

input:user-valid {
  border-color: #16a34a;
}

If you need to support older browsers, the classic trick combines :not(:focus) with :not(:placeholder-shown) so styling only appears after the user leaves a non-empty field:

/* Fallback for browsers without :user-invalid */
input:not(:focus):not(:placeholder-shown):invalid {
  border-color: #dc2626;
}

The Constraint Validation API

Attributes cover the common rules. For everything else — "passwords must match", "you must be 13 or older", "this username is already taken" — you drop into JavaScript. The Constraint Validation API lets you read why a field is invalid and inject your own rules and messages without abandoning the native machinery.

Reading validity

Every form control has a validity object — a ValidityState whose boolean flags tell you exactly what's wrong:

const email = document.querySelector('#email');

email.validity.valueMissing;   // true if required but empty
email.validity.typeMismatch;   // true if not a valid email/url
email.validity.patternMismatch;// true if it fails the pattern
email.validity.tooShort;       // true if below minlength
email.validity.rangeOverflow;  // true if above max
email.validity.valid;          // true if it passes everything

Useful methods

  • checkValidity() — returns true/false; fires an invalid event if it fails, but shows nothing.
  • reportValidity() — same check, but also pops up the browser's message on the first invalid field.
  • setCustomValidity(message) — set a non-empty string to mark the field invalid with your text; set '' to clear it and mark it valid again.

⚠️ Remember to clear custom errors

A field marked with setCustomValidity('some message') stays invalid forever until you call setCustomValidity(''). Forgetting this is the number-one custom-validation bug — the form silently refuses to submit even after the user fixes the problem. Re-run your check on every input event and clear the message when the value is good.

Example: passwords must match

const password = document.querySelector('#password');
const confirm = document.querySelector('#confirm');

function checkMatch() {
  // Clear first, then set only if they differ
  confirm.setCustomValidity('');
  if (confirm.value !== password.value) {
    confirm.setCustomValidity('Passwords do not match.');
  }
}

password.addEventListener('input', checkMatch);
confirm.addEventListener('input', checkMatch);

Worked Example: A Self-Validating Sign-Up Form

Let's tie it together. This form uses native attributes for the routine rules, custom validity for the password match, and shows a friendly message under each field instead of the default browser tooltip. Note novalidate on the form — it disables the browser's own popups so we can present our own UI, while still using the validity data underneath.

<form id="signup" novalidate>
  <div class="field">
    <label for="username">Username</label>
    <input type="text" id="username" name="username"
           required minlength="4" maxlength="20"
           pattern="[a-zA-Z0-9_-]+"
           title="Letters, numbers, underscores and hyphens only">
    <p class="error" aria-live="polite"></p>
  </div>

  <div class="field">
    <label for="email">Email</label>
    <input type="email" id="email" name="email" required>
    <p class="error" aria-live="polite"></p>
  </div>

  <div class="field">
    <label for="password">Password</label>
    <input type="password" id="password" name="password"
           required minlength="8">
    <p class="error" aria-live="polite"></p>
  </div>

  <div class="field">
    <label for="confirm">Confirm password</label>
    <input type="password" id="confirm" name="confirm" required>
    <p class="error" aria-live="polite"></p>
  </div>

  <button type="submit">Create account</button>
</form>

And the script. We generate readable messages from the validity flags, keep the password fields in sync, and only block submission if something is genuinely wrong:

const form = document.querySelector('#signup');
const password = form.password;
const confirm = form.confirm;

// Turn a field's validity state into a human message
function messageFor(field) {
  const v = field.validity;
  if (v.valid) return '';
  if (v.valueMissing)   return 'This field is required.';
  if (v.typeMismatch)   return `Please enter a valid ${field.type}.`;
  if (v.tooShort)       return `Must be at least ${field.minLength} characters.`;
  if (v.tooLong)        return `Must be at most ${field.maxLength} characters.`;
  if (v.patternMismatch)return field.title || 'Please match the requested format.';
  if (v.customError)    return field.validationMessage;
  return 'Please check this value.';
}

function showError(field) {
  const errorEl = field.parentElement.querySelector('.error');
  errorEl.textContent = messageFor(field);
  field.setAttribute('aria-invalid', field.validity.valid ? 'false' : 'true');
}

// Keep the confirm field's custom error in sync
function syncPasswords() {
  confirm.setCustomValidity('');
  if (confirm.value && confirm.value !== password.value) {
    confirm.setCustomValidity('Passwords do not match.');
  }
}

// Validate each field as the user leaves it
form.querySelectorAll('input').forEach(field => {
  field.addEventListener('blur', () => showError(field));
  field.addEventListener('input', () => {
    if (field === password || field === confirm) syncPasswords();
    if (field.getAttribute('aria-invalid') === 'true') showError(field);
  });
});

// On submit, check everything and stop if invalid
form.addEventListener('submit', (event) => {
  syncPasswords();
  if (!form.checkValidity()) {
    event.preventDefault();
    form.querySelectorAll('input').forEach(showError);
    form.querySelector(':invalid')?.focus();
  }
});

What the user experiences

Type a three-letter username and tab away → "Must be at least 4 characters." appears beneath it. Fix it and the message disappears. Mistype the confirmation → "Passwords do not match." The submit button never fires a request until every field is genuinely valid, and focus jumps to the first problem.

Accessible Error Messaging

Validation that only a sighted mouse user can perceive isn't finished. A red border means nothing to a screen-reader user, and color alone fails people with color-vision differences. A few attributes make errors perceivable to everyone.

  • Always pair color with text and/or an icon — never rely on red alone.
  • aria-invalid="true" tells assistive tech the field is in an error state.
  • aria-describedby links the input to both its hint and its error message so they're announced.
  • A live region (aria-live="polite" or role="alert") makes the error announce itself when it appears.
  • Move focus to the first invalid field on a failed submit so keyboard users land on the problem.
<label for="email">Email</label>
<input type="email" id="email" name="email" required
       aria-describedby="email-hint email-error"
       aria-invalid="false">
<p id="email-hint">We'll only use this to confirm your account.</p>
<p id="email-error" class="error" role="alert"></p>

✅ The takeaway

Native HTML5 validation is accessible by default — the browser's own messages are announced correctly. The moment you build custom UI, you inherit that responsibility. The attributes above restore it.

Hands-on Exercise

🏋️ Build an Event Registration Form

Objective: Combine native attributes and one custom rule into a working, accessible form.

Requirements:

  1. A full name field: required, minimum 2 characters.
  2. An email field: required, type="email".
  3. A ticket quantity field: type="number", between 1 and 8.
  4. A promo code field: optional, but if filled it must match the pattern SAVE[0-9]{2} (like SAVE20).
  5. A custom rule: the form may only submit if a "I agree to the terms" checkbox is checked — show your own message, not the default.
  6. Show each error inline beneath its field and set aria-invalid appropriately.
💡 Hint

Put novalidate on the <form> so you control the messaging. For the promo code, the pattern attribute is enough — an empty optional field is always considered valid, so you get "optional but formatted if present" for free. For the checkbox rule, listen on submit, and if it's unchecked call event.preventDefault() and write a message into a live region.

✅ Solution
<form id="register" novalidate>
  <div class="field">
    <label for="name">Full name</label>
    <input id="name" name="name" required minlength="2">
    <p class="error" aria-live="polite"></p>
  </div>
  <div class="field">
    <label for="email">Email</label>
    <input type="email" id="email" name="email" required>
    <p class="error" aria-live="polite"></p>
  </div>
  <div class="field">
    <label for="qty">Tickets (1–8)</label>
    <input type="number" id="qty" name="qty" min="1" max="8" required>
    <p class="error" aria-live="polite"></p>
  </div>
  <div class="field">
    <label for="promo">Promo code (optional)</label>
    <input id="promo" name="promo" pattern="SAVE[0-9]{2}"
           title="Format: SAVE followed by two digits, e.g. SAVE20">
    <p class="error" aria-live="polite"></p>
  </div>
  <div class="field">
    <label><input type="checkbox" id="terms" name="terms"> I agree to the terms</label>
    <p class="error" id="terms-error" role="alert"></p>
  </div>
  <button type="submit">Register</button>
</form>

<script>
const form = document.querySelector('#register');

function messageFor(field) {
  const v = field.validity;
  if (v.valid) return '';
  if (v.valueMissing)    return 'This field is required.';
  if (v.typeMismatch)    return 'Please enter a valid email.';
  if (v.tooShort)        return `At least ${field.minLength} characters.`;
  if (v.rangeUnderflow)  return `Minimum is ${field.min}.`;
  if (v.rangeOverflow)   return `Maximum is ${field.max}.`;
  if (v.patternMismatch) return field.title;
  return 'Please check this value.';
}

function showError(field) {
  const el = field.closest('.field').querySelector('.error');
  if (el) el.textContent = messageFor(field);
  field.setAttribute('aria-invalid', field.validity.valid ? 'false' : 'true');
}

form.querySelectorAll('input:not([type=checkbox])').forEach(f => {
  f.addEventListener('blur', () => showError(f));
});

form.addEventListener('submit', (event) => {
  let ok = form.checkValidity();
  form.querySelectorAll('input:not([type=checkbox])').forEach(showError);

  const terms = form.terms;
  const termsError = document.querySelector('#terms-error');
  if (!terms.checked) {
    termsError.textContent = 'You must agree to the terms to register.';
    ok = false;
  } else {
    termsError.textContent = '';
  }

  if (!ok) {
    event.preventDefault();
    form.querySelector(':invalid, input:not(:checked)[name=terms]')?.focus();
  }
});
</script>

🎯 Quick Quiz

Question 1: Why must you always keep server-side validation even when your HTML5 client-side validation is thorough?

Question 2: You call field.setCustomValidity('Passwords do not match.'). The user then fixes the value but the form still won't submit. What did you forget?

Question 3: Which approach avoids painting every required field red before the user has typed anything?

Best Practices

✅ Do

  • Validate on the server for every submission, no exceptions.
  • Prefer native attributes over hand-rolled JavaScript wherever they cover the rule.
  • Show errors after interaction (:user-invalid / blur), not on page load.
  • Write messages that say what's wrong and how to fix it.
  • Set aria-invalid and link errors with aria-describedby.

⚠️ Don't

  • Don't over-restrict type="email" with a strict pattern — you'll reject valid, unusual-but-real addresses.
  • Don't rely on color alone to signal an error.
  • Don't forget to clear setCustomValidity('') when the problem is resolved.
  • Don't disable the submit button as your only guard — it's confusing and inaccessible; validate on submit instead.

Summary & Quiz

🎉 Key Takeaways

  • Client-side validation is for user experience; server-side validation is for security — you need both, always.
  • Native attributes (required, type, pattern, min/max, minlength/maxlength) handle most rules for free and accessibly.
  • Style validity with :user-invalid to avoid premature red states.
  • The Constraint Validation API (validity, setCustomValidity, reportValidity) powers custom rules — and remember to clear custom errors.
  • Custom UI means you own accessibility: aria-invalid, aria-describedby, and live regions.

📚 Further Reading

🚀 What's Next?

Now that your forms can guard their data, the next lesson explores Advanced Input Types and Features — date and time pickers, sliders, color pickers, datalists, and file uploads that make data entry faster and more pleasant, especially on mobile.

🎉 Well done!

You can now build forms that catch mistakes early, explain them clearly, and never blindly trust the browser.