Frontend

Form Validation Without a Framework

A comprehensive guide to client-side form validation using HTML5 Constraint Validation API, custom validators, accessible error display, and real-time feedback without frameworks.

Form Validation Without a Framework

Form validation is one of those problems that every developer encounters, and most reach for a library before even considering what the browser already provides. That is a mistake. The HTML5 Constraint Validation API is powerful, well-supported, and pairs naturally with vanilla JavaScript to handle everything from simple required fields to async server-side checks. You do not need React Hook Form, Formik, or Yup to build robust, accessible form validation. You need to understand the platform.

In this article, I will walk through everything you need to build production-quality form validation from scratch: the native HTML5 APIs, custom validation logic, accessible error reporting, real-time feedback, and a complete working registration form that handles async username checks, password strength meters, and multi-field dependencies — all without a single framework dependency.

Prerequisites

  • Solid understanding of HTML forms and input types
  • Intermediate JavaScript (DOM manipulation, event handling, Promises)
  • Basic CSS (pseudo-classes, selectors)
  • Familiarity with ARIA attributes is helpful but not required

HTML5 Constraint Validation API

Modern browsers ship with a built-in validation system that most developers barely scratch the surface of. By adding a few attributes to your HTML, the browser handles validation automatically.

Built-in Validation Attributes

<form id="basic-form" novalidate>
  <!-- Required field -->
  <input type="text" name="fullName" required />

  <!-- Email type validation -->
  <input type="email" name="email" required />

  <!-- Pattern matching (US phone) -->
  <input type="tel" name="phone" pattern="^\d{3}-\d{3}-\d{4}$" />

  <!-- Numeric range -->
  <input type="number" name="age" min="18" max="120" />

  <!-- Length constraints -->
  <input type="text" name="username" minlength="3" maxlength="20" required />

  <!-- URL type -->
  <input type="url" name="website" />

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

Notice the novalidate attribute on the form. This disables the browser's default validation popups, giving you full control over how and when errors are displayed. I always recommend this approach because the default browser validation UI is inconsistent across browsers and impossible to style.

checkValidity and reportValidity

Every form element and the form itself exposes two critical methods:

var form = document.getElementById('basic-form');
var emailInput = form.querySelector('[name="email"]');

// Returns true/false, fires 'invalid' event on failure
var isEmailValid = emailInput.checkValidity();

// Returns true/false AND shows browser's default error popup
var showError = emailInput.reportValidity();

// Check entire form at once
var isFormValid = form.checkValidity();

checkValidity() is the workhorse. It checks the element against all its constraint attributes and returns a boolean. If validation fails, it fires an invalid event on the element. reportValidity() does the same thing but also triggers the browser's native error tooltip — which you typically want to avoid if you are building custom error displays.

The validity Property

Every input element has a validity property that returns a ValidityState object. This is where the real power lives — it tells you exactly why a field is invalid:

var input = document.querySelector('[name="email"]');

// ValidityState properties (all boolean)
input.validity.valueMissing;    // required field is empty
input.validity.typeMismatch;    // doesn't match type (email, url, etc.)
input.validity.patternMismatch; // doesn't match pattern attribute
input.validity.tooShort;        // shorter than minlength
input.validity.tooLong;         // longer than maxlength
input.validity.rangeUnderflow;  // less than min
input.validity.rangeOverflow;   // greater than max
input.validity.stepMismatch;    // doesn't match step attribute
input.validity.badInput;        // browser can't parse the input
input.validity.customError;     // setCustomValidity was called
input.validity.valid;           // true if all checks pass

You can use these properties to build precise, user-friendly error messages:

function getErrorMessage(input) {
  var validity = input.validity;

  if (validity.valueMissing) {
    return input.dataset.errorRequired || 'This field is required.';
  }
  if (validity.typeMismatch) {
    return input.dataset.errorType || 'Please enter a valid ' + input.type + '.';
  }
  if (validity.patternMismatch) {
    return input.dataset.errorPattern || 'Please match the requested format.';
  }
  if (validity.tooShort) {
    return 'Must be at least ' + input.minLength + ' characters. You entered ' + input.value.length + '.';
  }
  if (validity.tooLong) {
    return 'Must be no more than ' + input.maxLength + ' characters.';
  }
  if (validity.rangeUnderflow) {
    return 'Must be at least ' + input.min + '.';
  }
  if (validity.rangeOverflow) {
    return 'Must be no more than ' + input.max + '.';
  }
  if (validity.customError) {
    return input.validationMessage;
  }
  return 'Invalid value.';
}

setCustomValidity for Custom Messages

The setCustomValidity() method lets you inject your own error messages into the Constraint Validation system. When you set a non-empty string, the field becomes invalid. Setting it back to an empty string clears the custom error:

var passwordInput = document.querySelector('[name="password"]');

function validatePasswordStrength(input) {
  var value = input.value;

  if (value.length < 8) {
    input.setCustomValidity('Password must be at least 8 characters.');
    return false;
  }
  if (!/[A-Z]/.test(value)) {
    input.setCustomValidity('Password must contain at least one uppercase letter.');
    return false;
  }
  if (!/[0-9]/.test(value)) {
    input.setCustomValidity('Password must contain at least one number.');
    return false;
  }

  input.setCustomValidity('');
  return true;
}

This is critical: you must call setCustomValidity('') to clear the error when the input becomes valid. Forgetting this is one of the most common bugs I see with custom validation.

Custom Validation Beyond HTML5

The built-in attributes only get you so far. Real applications need password matching, async server lookups, and business logic validation.

Password Matching

function validatePasswordMatch(passwordInput, confirmInput) {
  if (confirmInput.value !== passwordInput.value) {
    confirmInput.setCustomValidity('Passwords do not match.');
    return false;
  }
  confirmInput.setCustomValidity('');
  return true;
}

Async Validation (Username Availability)

Async validation requires a different pattern. You cannot use setCustomValidity inside an event handler and expect checkValidity() to wait for a Promise. Instead, manage async state explicitly:

var pendingValidation = {};

function checkUsernameAvailability(input) {
  var username = input.value.trim();
  if (username.length < 3) return Promise.resolve();

  pendingValidation.username = true;
  showFieldStatus(input, 'checking', 'Checking availability...');

  return fetch('/api/check-username?username=' + encodeURIComponent(username))
    .then(function(response) { return response.json(); })
    .then(function(data) {
      pendingValidation.username = false;
      if (!data.available) {
        input.setCustomValidity('This username is already taken.');
        showFieldError(input, 'This username is already taken.');
      } else {
        input.setCustomValidity('');
        showFieldSuccess(input);
      }
    })
    .catch(function(err) {
      pendingValidation.username = false;
      input.setCustomValidity('');
      // Don't block submission on network errors — let server-side validation catch it
    });
}

Debounced Validation for Expensive Checks

You do not want to fire an API call on every keystroke. Debounce expensive checks:

function debounce(fn, delay) {
  var timer = null;
  return function() {
    var context = this;
    var args = arguments;
    clearTimeout(timer);
    timer = setTimeout(function() {
      fn.apply(context, args);
    }, delay);
  };
}

var debouncedUsernameCheck = debounce(function(input) {
  checkUsernameAvailability(input);
}, 400);

document.querySelector('[name="username"]').addEventListener('input', function() {
  debouncedUsernameCheck(this);
});

Four hundred milliseconds is my go-to debounce interval. It is long enough to avoid hammering the server while the user types, but short enough that the feedback still feels responsive.

Styling Valid and Invalid States

CSS pseudo-classes let you style inputs based on their validation state without JavaScript:

/* Standard pseudo-classes */
input:valid {
  border-color: #28a745;
}

input:invalid {
  border-color: #dc3545;
}

/* :user-invalid only applies after user interaction — much better UX */
input:user-invalid {
  border-color: #dc3545;
  background-color: #fff5f5;
}

/* Focus states */
input:focus:invalid {
  outline-color: #dc3545;
  box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.25);
}

input:focus:valid {
  outline-color: #28a745;
  box-shadow: 0 0 0 3px rgba(40, 167, 69, 0.25);
}

The :user-invalid pseudo-class is a game-changer. Unlike :invalid, which applies immediately (making empty required fields red before the user has even interacted with them), :user-invalid only activates after the user has interacted with and left the field. Support is strong in modern browsers. For older browsers, track interaction state manually with a CSS class:

document.querySelectorAll('input, select, textarea').forEach(function(el) {
  el.addEventListener('blur', function() {
    this.classList.add('touched');
  });
});
input.touched:invalid {
  border-color: #dc3545;
}

Real-Time vs On-Submit Validation UX

There is a long-running debate about when to validate. Here is my opinion after building dozens of production forms: validate on blur, re-validate on input after the first error, and do a full check on submit.

function setupRealtimeValidation(form) {
  var fields = form.querySelectorAll('input, select, textarea');

  fields.forEach(function(field) {
    // Validate when user leaves the field
    field.addEventListener('blur', function() {
      validateField(this);
    });

    // Re-validate on input, but only if already showing an error
    field.addEventListener('input', function() {
      if (this.classList.contains('is-invalid')) {
        validateField(this);
      }
    });
  });

  form.addEventListener('submit', function(e) {
    e.preventDefault();
    var isValid = validateAllFields(form);
    if (isValid) {
      submitForm(form);
    }
  });
}

This pattern avoids the frustration of seeing errors before you have finished typing, while giving immediate feedback when you are correcting a mistake.

Accessible Error Messages

Validation is useless if screen reader users cannot understand the errors. Accessibility is not optional — it is a requirement.

ARIA Attributes for Error Display

<div class="form-group">
  <label for="email">Email Address</label>
  <input
    type="email"
    id="email"
    name="email"
    required
    aria-describedby="email-error"
    aria-invalid="false"
  />
  <div id="email-error" class="error-message" role="alert" aria-live="polite"></div>
</div>
function showFieldError(input, message) {
  var errorEl = document.getElementById(input.id + '-error');
  if (!errorEl) return;

  input.classList.add('is-invalid');
  input.classList.remove('is-valid');
  input.setAttribute('aria-invalid', 'true');

  errorEl.textContent = message;
  errorEl.style.display = 'block';
}

function showFieldSuccess(input) {
  var errorEl = document.getElementById(input.id + '-error');
  if (!errorEl) return;

  input.classList.remove('is-invalid');
  input.classList.add('is-valid');
  input.setAttribute('aria-invalid', 'false');

  errorEl.textContent = '';
  errorEl.style.display = 'none';
}

Key points: aria-invalid tells assistive technology whether the field is in an error state. aria-describedby links the input to its error message element. role="alert" with aria-live="polite" ensures screen readers announce errors when they appear without interrupting the user.

File Input Validation

File inputs need special handling since the Constraint Validation API does not cover file size or detailed MIME type validation:

function validateFileInput(input, options) {
  var maxSize = options.maxSize || 5 * 1024 * 1024; // 5MB default
  var allowedTypes = options.allowedTypes || [];
  var files = input.files;

  if (files.length === 0 && input.required) {
    input.setCustomValidity('Please select a file.');
    return false;
  }

  for (var i = 0; i < files.length; i++) {
    var file = files[i];

    if (file.size > maxSize) {
      var sizeMB = (maxSize / (1024 * 1024)).toFixed(0);
      input.setCustomValidity('File "' + file.name + '" exceeds the ' + sizeMB + 'MB limit.');
      return false;
    }

    if (allowedTypes.length > 0 && allowedTypes.indexOf(file.type) === -1) {
      input.setCustomValidity('File type "' + file.type + '" is not allowed. Accepted: ' + allowedTypes.join(', '));
      return false;
    }
  }

  input.setCustomValidity('');
  return true;
}

Preventing Double Submission

Double submission is a classic problem. Disable the submit button and track submission state:

function submitForm(form) {
  var submitBtn = form.querySelector('[type="submit"]');
  var originalText = submitBtn.textContent;

  if (form.dataset.submitting === 'true') return;

  form.dataset.submitting = 'true';
  submitBtn.disabled = true;
  submitBtn.textContent = 'Submitting...';

  var formData = new FormData(form);

  fetch(form.action, {
    method: form.method || 'POST',
    body: formData
  })
  .then(function(response) {
    if (!response.ok) throw new Error('Submission failed');
    return response.json();
  })
  .then(function(data) {
    showFormSuccess(form, 'Registration complete!');
  })
  .catch(function(err) {
    showFormError(form, 'Something went wrong. Please try again.');
    submitBtn.disabled = false;
    submitBtn.textContent = originalText;
    form.dataset.submitting = 'false';
  });
}

Form Data Extraction with FormData API

The FormData API is the cleanest way to extract form values. It handles all input types including files, checkboxes, and radio buttons:

function extractFormData(form) {
  var formData = new FormData(form);
  var data = {};

  formData.forEach(function(value, key) {
    // Handle multiple values for same key (checkboxes, multi-select)
    if (data[key]) {
      if (!Array.isArray(data[key])) {
        data[key] = [data[key]];
      }
      data[key].push(value);
    } else {
      data[key] = value;
    }
  });

  return data;
}

// For JSON submission
function submitAsJSON(form) {
  var data = extractFormData(form);
  return fetch(form.action, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data)
  });
}

Custom Validation Engine Architecture

For complex forms, centralize your validation logic into a reusable engine:

function ValidationEngine(form) {
  this.form = form;
  this.validators = {};
  this.asyncValidators = {};
  this.errors = {};
}

ValidationEngine.prototype.addRule = function(fieldName, validatorFn, message) {
  if (!this.validators[fieldName]) {
    this.validators[fieldName] = [];
  }
  this.validators[fieldName].push({ validate: validatorFn, message: message });
  return this;
};

ValidationEngine.prototype.addAsyncRule = function(fieldName, validatorFn, message) {
  if (!this.asyncValidators[fieldName]) {
    this.asyncValidators[fieldName] = [];
  }
  this.asyncValidators[fieldName].push({ validate: validatorFn, message: message });
  return this;
};

ValidationEngine.prototype.validateField = function(fieldName) {
  var input = this.form.querySelector('[name="' + fieldName + '"]');
  if (!input) return true;

  // Run HTML5 validation first
  if (!input.checkValidity()) {
    var message = getErrorMessage(input);
    this.errors[fieldName] = message;
    showFieldError(input, message);
    return false;
  }

  // Run custom synchronous validators
  var rules = this.validators[fieldName] || [];
  for (var i = 0; i < rules.length; i++) {
    if (!rules[i].validate(input.value, this.form)) {
      this.errors[fieldName] = rules[i].message;
      showFieldError(input, rules[i].message);
      return false;
    }
  }

  showFieldSuccess(input);
  delete this.errors[fieldName];
  return true;
};

ValidationEngine.prototype.validateFieldAsync = function(fieldName) {
  var self = this;
  var input = this.form.querySelector('[name="' + fieldName + '"]');
  var asyncRules = this.asyncValidators[fieldName] || [];

  if (asyncRules.length === 0) return Promise.resolve(true);

  var promises = asyncRules.map(function(rule) {
    return rule.validate(input.value, self.form).then(function(isValid) {
      if (!isValid) {
        self.errors[fieldName] = rule.message;
        showFieldError(input, rule.message);
      }
      return isValid;
    });
  });

  return Promise.all(promises).then(function(results) {
    return results.every(function(r) { return r; });
  });
};

ValidationEngine.prototype.validateAll = function() {
  var self = this;
  var fields = Object.keys(this.validators);
  var allValid = true;

  fields.forEach(function(fieldName) {
    if (!self.validateField(fieldName)) {
      allValid = false;
    }
  });

  return allValid;
};

Internationalization of Error Messages

For multilingual apps, externalize your error messages:

var ValidationMessages = {
  en: {
    required: 'This field is required.',
    email: 'Please enter a valid email address.',
    minLength: 'Must be at least {min} characters.',
    maxLength: 'Must be no more than {max} characters.',
    passwordMatch: 'Passwords do not match.',
    usernameTaken: 'This username is already taken.'
  },
  es: {
    required: 'Este campo es obligatorio.',
    email: 'Ingrese una direccion de correo valida.',
    minLength: 'Debe tener al menos {min} caracteres.',
    maxLength: 'No debe exceder {max} caracteres.',
    passwordMatch: 'Las contrasenas no coinciden.',
    usernameTaken: 'Este nombre de usuario ya esta en uso.'
  }
};

var currentLocale = document.documentElement.lang || 'en';

function t(key, params) {
  var messages = ValidationMessages[currentLocale] || ValidationMessages['en'];
  var msg = messages[key] || key;

  if (params) {
    Object.keys(params).forEach(function(param) {
      msg = msg.replace('{' + param + '}', params[param]);
    });
  }

  return msg;
}

// Usage
showFieldError(input, t('minLength', { min: 8 }));

Multi-Step Form Validation

For wizard-style forms, validate each step before allowing progression:

function MultiStepForm(form) {
  this.form = form;
  this.steps = form.querySelectorAll('.form-step');
  this.currentStep = 0;
  this.engine = new ValidationEngine(form);
}

MultiStepForm.prototype.validateCurrentStep = function() {
  var step = this.steps[this.currentStep];
  var inputs = step.querySelectorAll('input, select, textarea');
  var allValid = true;

  for (var i = 0; i < inputs.length; i++) {
    var fieldName = inputs[i].name;
    if (fieldName && !this.engine.validateField(fieldName)) {
      allValid = false;
    }
  }

  return allValid;
};

MultiStepForm.prototype.next = function() {
  if (this.validateCurrentStep() && this.currentStep < this.steps.length - 1) {
    this.steps[this.currentStep].style.display = 'none';
    this.currentStep++;
    this.steps[this.currentStep].style.display = 'block';
  }
};

MultiStepForm.prototype.prev = function() {
  if (this.currentStep > 0) {
    this.steps[this.currentStep].style.display = 'none';
    this.currentStep--;
    this.steps[this.currentStep].style.display = 'block';
  }
};

Complete Working Example

Here is a full registration form with username availability checking, email validation, password strength metering, confirm password matching, phone pattern validation, and a terms checkbox — all validated without any framework:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Registration Form</title>
  <style>
    *, *::before, *::after { box-sizing: border-box; }
    body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 480px; margin: 40px auto; padding: 0 20px; color: #333; }
    h1 { font-size: 1.5rem; margin-bottom: 24px; }
    .form-group { margin-bottom: 20px; }
    label { display: block; font-weight: 600; margin-bottom: 6px; font-size: 0.9rem; }
    input[type="text"], input[type="email"], input[type="password"], input[type="tel"] {
      width: 100%; padding: 10px 12px; border: 2px solid #ccc; border-radius: 6px; font-size: 1rem; transition: border-color 0.2s, box-shadow 0.2s;
    }
    input:focus { outline: none; border-color: #4a90d9; box-shadow: 0 0 0 3px rgba(74, 144, 217, 0.2); }
    input.is-valid { border-color: #28a745; }
    input.is-invalid { border-color: #dc3545; }
    input.is-invalid:focus { box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.2); }
    .error-message { color: #dc3545; font-size: 0.85rem; margin-top: 4px; display: none; }
    .field-hint { color: #666; font-size: 0.8rem; margin-top: 4px; }
    .field-status { font-size: 0.85rem; margin-top: 4px; }
    .field-status.checking { color: #856404; }
    .field-status.available { color: #28a745; }
    .strength-meter { height: 6px; border-radius: 3px; margin-top: 6px; background: #e9ecef; overflow: hidden; }
    .strength-meter .fill { height: 100%; transition: width 0.3s, background-color 0.3s; }
    .strength-label { font-size: 0.8rem; margin-top: 2px; }
    .checkbox-group { display: flex; align-items: flex-start; gap: 8px; }
    .checkbox-group input { margin-top: 4px; }
    button[type="submit"] {
      width: 100%; padding: 12px; background: #4a90d9; color: white; border: none; border-radius: 6px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: background 0.2s;
    }
    button[type="submit"]:hover { background: #357abd; }
    button[type="submit"]:disabled { background: #94bde6; cursor: not-allowed; }
    .form-success { background: #d4edda; border: 1px solid #c3e6cb; color: #155724; padding: 16px; border-radius: 6px; display: none; }
    .form-error-summary { background: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; padding: 16px; border-radius: 6px; margin-bottom: 16px; display: none; }
  </style>
</head>
<body>

<h1>Create Your Account</h1>

<div id="form-success" class="form-success" role="alert">
  <strong>Registration successful!</strong> Check your email to verify your account.
</div>

<div id="form-error-summary" class="form-error-summary" role="alert" aria-live="assertive"></div>

<form id="registration-form" action="/api/register" method="POST" novalidate>

  <div class="form-group">
    <label for="username">Username</label>
    <input type="text" id="username" name="username" required minlength="3" maxlength="20"
           pattern="^[a-zA-Z0-9_]+$" autocomplete="username"
           aria-describedby="username-hint username-error username-status" aria-invalid="false" />
    <div id="username-hint" class="field-hint">3-20 characters. Letters, numbers, and underscores only.</div>
    <div id="username-status" class="field-status"></div>
    <div id="username-error" class="error-message" role="alert" aria-live="polite"></div>
  </div>

  <div class="form-group">
    <label for="email">Email Address</label>
    <input type="email" id="email" name="email" required autocomplete="email"
           aria-describedby="email-error" aria-invalid="false" />
    <div id="email-error" class="error-message" role="alert" aria-live="polite"></div>
  </div>

  <div class="form-group">
    <label for="password">Password</label>
    <input type="password" id="password" name="password" required minlength="8"
           autocomplete="new-password"
           aria-describedby="password-hint password-error" aria-invalid="false" />
    <div class="strength-meter" id="password-strength-meter">
      <div class="fill" id="password-strength-fill"></div>
    </div>
    <div class="strength-label" id="password-strength-label"></div>
    <div id="password-hint" class="field-hint">At least 8 characters with uppercase, lowercase, and a number.</div>
    <div id="password-error" class="error-message" role="alert" aria-live="polite"></div>
  </div>

  <div class="form-group">
    <label for="confirmPassword">Confirm Password</label>
    <input type="password" id="confirmPassword" name="confirmPassword" required
           autocomplete="new-password"
           aria-describedby="confirmPassword-error" aria-invalid="false" />
    <div id="confirmPassword-error" class="error-message" role="alert" aria-live="polite"></div>
  </div>

  <div class="form-group">
    <label for="phone">Phone Number <span style="font-weight:normal;color:#999;">(optional)</span></label>
    <input type="tel" id="phone" name="phone" pattern="^\d{3}-\d{3}-\d{4}$"
           placeholder="555-123-4567" autocomplete="tel"
           aria-describedby="phone-hint phone-error" aria-invalid="false" />
    <div id="phone-hint" class="field-hint">Format: 555-123-4567</div>
    <div id="phone-error" class="error-message" role="alert" aria-live="polite"></div>
  </div>

  <div class="form-group">
    <div class="checkbox-group">
      <input type="checkbox" id="terms" name="terms" required aria-describedby="terms-error" aria-invalid="false" />
      <label for="terms" style="font-weight:normal;">I agree to the <a href="/terms">Terms of Service</a> and <a href="/privacy">Privacy Policy</a>.</label>
    </div>
    <div id="terms-error" class="error-message" role="alert" aria-live="polite"></div>
  </div>

  <button type="submit" id="submit-btn">Create Account</button>

</form>

<script>
(function() {
  var form = document.getElementById('registration-form');
  var submitBtn = document.getElementById('submit-btn');

  // --- Utility Functions ---

  function debounce(fn, delay) {
    var timer = null;
    return function() {
      var context = this;
      var args = arguments;
      clearTimeout(timer);
      timer = setTimeout(function() { fn.apply(context, args); }, delay);
    };
  }

  function showError(input, message) {
    var errorEl = document.getElementById(input.id + '-error');
    if (!errorEl) return;
    input.classList.add('is-invalid');
    input.classList.remove('is-valid');
    input.setAttribute('aria-invalid', 'true');
    errorEl.textContent = message;
    errorEl.style.display = 'block';
  }

  function showSuccess(input) {
    var errorEl = document.getElementById(input.id + '-error');
    if (!errorEl) return;
    input.classList.remove('is-invalid');
    input.classList.add('is-valid');
    input.setAttribute('aria-invalid', 'false');
    errorEl.textContent = '';
    errorEl.style.display = 'none';
  }

  function clearStatus(input) {
    input.classList.remove('is-invalid', 'is-valid');
    input.setAttribute('aria-invalid', 'false');
    var errorEl = document.getElementById(input.id + '-error');
    if (errorEl) { errorEl.textContent = ''; errorEl.style.display = 'none'; }
  }

  function getErrorMessage(input) {
    var v = input.validity;
    if (v.valueMissing) return input.dataset.errorRequired || 'This field is required.';
    if (v.typeMismatch) return 'Please enter a valid ' + input.type + ' address.';
    if (v.patternMismatch) return input.dataset.errorPattern || 'Please match the requested format.';
    if (v.tooShort) return 'Must be at least ' + input.minLength + ' characters.';
    if (v.tooLong) return 'Must be no more than ' + input.maxLength + ' characters.';
    if (v.customError) return input.validationMessage;
    return 'Invalid value.';
  }

  // --- Password Strength ---

  function calcPasswordStrength(password) {
    var score = 0;
    if (password.length >= 8) score++;
    if (password.length >= 12) score++;
    if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score++;
    if (/[0-9]/.test(password)) score++;
    if (/[^a-zA-Z0-9]/.test(password)) score++;
    return score; // 0-5
  }

  function updateStrengthMeter(password) {
    var score = calcPasswordStrength(password);
    var fill = document.getElementById('password-strength-fill');
    var label = document.getElementById('password-strength-label');
    var levels = [
      { width: '0%', color: '#e9ecef', text: '' },
      { width: '20%', color: '#dc3545', text: 'Very weak' },
      { width: '40%', color: '#fd7e14', text: 'Weak' },
      { width: '60%', color: '#ffc107', text: 'Fair' },
      { width: '80%', color: '#28a745', text: 'Strong' },
      { width: '100%', color: '#155724', text: 'Very strong' }
    ];
    var level = levels[score];
    fill.style.width = level.width;
    fill.style.backgroundColor = level.color;
    label.textContent = level.text;
    label.style.color = level.color;
  }

  // --- Async Username Check ---

  var usernameAvailable = null;

  var debouncedUsernameCheck = debounce(function(input) {
    var username = input.value.trim();
    if (username.length < 3) {
      usernameAvailable = null;
      document.getElementById('username-status').textContent = '';
      return;
    }

    var statusEl = document.getElementById('username-status');
    statusEl.className = 'field-status checking';
    statusEl.textContent = 'Checking availability...';

    fetch('/api/check-username?username=' + encodeURIComponent(username))
      .then(function(res) { return res.json(); })
      .then(function(data) {
        if (input.value.trim() !== username) return; // stale response
        usernameAvailable = data.available;
        if (data.available) {
          statusEl.className = 'field-status available';
          statusEl.textContent = 'Username is available!';
          input.setCustomValidity('');
          showSuccess(input);
        } else {
          statusEl.className = 'field-status';
          statusEl.textContent = '';
          input.setCustomValidity('This username is already taken.');
          showError(input, 'This username is already taken.');
        }
      })
      .catch(function() {
        usernameAvailable = null;
        statusEl.textContent = '';
        input.setCustomValidity('');
      });
  }, 400);

  // --- Field Validators ---

  function validateUsername(input) {
    input.setCustomValidity('');
    if (!input.checkValidity()) {
      if (input.validity.patternMismatch) {
        showError(input, 'Only letters, numbers, and underscores are allowed.');
      } else {
        showError(input, getErrorMessage(input));
      }
      return false;
    }
    if (usernameAvailable === false) {
      input.setCustomValidity('This username is already taken.');
      showError(input, 'This username is already taken.');
      return false;
    }
    showSuccess(input);
    return true;
  }

  function validatePassword(input) {
    input.setCustomValidity('');
    var value = input.value;

    if (!value) {
      input.setCustomValidity('');
      if (!input.checkValidity()) { showError(input, getErrorMessage(input)); return false; }
    }

    if (value.length > 0 && value.length < 8) {
      input.setCustomValidity('Password must be at least 8 characters.');
      showError(input, 'Password must be at least 8 characters.');
      return false;
    }
    if (value.length >= 8 && !/[A-Z]/.test(value)) {
      input.setCustomValidity('Must contain at least one uppercase letter.');
      showError(input, 'Must contain at least one uppercase letter.');
      return false;
    }
    if (value.length >= 8 && !/[a-z]/.test(value)) {
      input.setCustomValidity('Must contain at least one lowercase letter.');
      showError(input, 'Must contain at least one lowercase letter.');
      return false;
    }
    if (value.length >= 8 && !/[0-9]/.test(value)) {
      input.setCustomValidity('Must contain at least one number.');
      showError(input, 'Must contain at least one number.');
      return false;
    }

    input.setCustomValidity('');
    if (!input.checkValidity()) { showError(input, getErrorMessage(input)); return false; }

    showSuccess(input);
    return true;
  }

  function validateConfirmPassword(input) {
    var passwordVal = document.getElementById('password').value;
    input.setCustomValidity('');

    if (!input.value && input.required) {
      showError(input, 'This field is required.');
      return false;
    }
    if (input.value && input.value !== passwordVal) {
      input.setCustomValidity('Passwords do not match.');
      showError(input, 'Passwords do not match.');
      return false;
    }

    input.setCustomValidity('');
    if (!input.checkValidity()) { showError(input, getErrorMessage(input)); return false; }

    if (input.value) showSuccess(input);
    return true;
  }

  function validatePhone(input) {
    input.setCustomValidity('');
    if (!input.value) { clearStatus(input); return true; } // optional
    if (!input.checkValidity()) {
      showError(input, 'Please use the format 555-123-4567.');
      return false;
    }
    showSuccess(input);
    return true;
  }

  function validateTerms(input) {
    if (!input.checked) {
      showError(input, 'You must agree to the terms to continue.');
      return false;
    }
    showSuccess(input);
    return true;
  }

  function validateEmail(input) {
    input.setCustomValidity('');
    if (!input.checkValidity()) {
      showError(input, getErrorMessage(input));
      return false;
    }
    showSuccess(input);
    return true;
  }

  // --- Validate Field by Name ---

  var fieldValidators = {
    username: validateUsername,
    email: validateEmail,
    password: validatePassword,
    confirmPassword: validateConfirmPassword,
    phone: validatePhone,
    terms: validateTerms
  };

  function validateField(input) {
    var validator = fieldValidators[input.name];
    if (validator) return validator(input);
    return true;
  }

  // --- Event Binding ---

  var fields = form.querySelectorAll('input');

  for (var i = 0; i < fields.length; i++) {
    (function(field) {
      // Validate on blur
      field.addEventListener('blur', function() {
        validateField(this);
      });

      // Re-validate on input if already invalid
      field.addEventListener('input', function() {
        if (this.classList.contains('is-invalid')) {
          validateField(this);
        }
      });
    })(fields[i]);
  }

  // Password-specific: update strength meter on every keystroke
  document.getElementById('password').addEventListener('input', function() {
    updateStrengthMeter(this.value);
    // Also re-validate confirm password if it has a value
    var confirmInput = document.getElementById('confirmPassword');
    if (confirmInput.value) { validateConfirmPassword(confirmInput); }
  });

  // Username-specific: async availability check
  document.getElementById('username').addEventListener('input', function() {
    debouncedUsernameCheck(this);
  });

  // Terms checkbox
  document.getElementById('terms').addEventListener('change', function() {
    validateField(this);
  });

  // --- Form Submission ---

  form.addEventListener('submit', function(e) {
    e.preventDefault();

    var allValid = true;
    var firstInvalid = null;

    for (var i = 0; i < fields.length; i++) {
      var isValid = validateField(fields[i]);
      if (!isValid && allValid) {
        allValid = false;
        firstInvalid = fields[i];
      }
    }

    if (!allValid) {
      var summaryEl = document.getElementById('form-error-summary');
      summaryEl.textContent = 'Please fix the errors above before submitting.';
      summaryEl.style.display = 'block';
      if (firstInvalid) firstInvalid.focus();
      return;
    }

    document.getElementById('form-error-summary').style.display = 'none';

    // Prevent double submission
    if (form.dataset.submitting === 'true') return;
    form.dataset.submitting = 'true';
    submitBtn.disabled = true;
    submitBtn.textContent = 'Creating Account...';

    var formData = new FormData(form);

    fetch(form.action, {
      method: 'POST',
      body: formData
    })
    .then(function(response) {
      if (!response.ok) throw new Error('Server error');
      return response.json();
    })
    .then(function(data) {
      form.style.display = 'none';
      document.getElementById('form-success').style.display = 'block';
    })
    .catch(function(err) {
      var summaryEl = document.getElementById('form-error-summary');
      summaryEl.textContent = 'Registration failed. Please try again.';
      summaryEl.style.display = 'block';
      submitBtn.disabled = false;
      submitBtn.textContent = 'Create Account';
      form.dataset.submitting = 'false';
    });
  });
})();
</script>

</body>
</html>

This example brings together every technique discussed in the article: HTML5 constraint attributes for baseline validation, setCustomValidity for custom rules, debounced async checks, a password strength meter, accessible ARIA attributes, real-time feedback on blur with re-validation on input, double submission prevention, and an error summary for form-level feedback.

Common Issues and Troubleshooting

Custom validity not clearing. The most common bug. If you call setCustomValidity('error message') but forget to call setCustomValidity('') when the input becomes valid, the field stays permanently invalid. Always clear it before running checkValidity().

Stale async validation responses. If a user types "shane", then quickly deletes it and types "sarah", the response for "shane" may arrive after "sarah" and overwrite the correct state. Always compare the current input value against the value that was sent in the request, and discard stale responses.

The novalidate attribute is missing. Without novalidate on the form, the browser will show its own validation tooltips alongside your custom error messages, creating a confusing double-error experience. Always add novalidate when building custom validation UI.

Checkbox and radio validation quirks. The required attribute on checkboxes only validates that the box is checked. For radio groups, required needs to be on at least one radio button in the group. The validity object works differently for these input types — valueMissing is true when no option is selected, not when the value is empty.

Pattern attribute requires a full match. The pattern attribute implicitly anchors with ^ and $. Writing pattern="\d{3}" actually matches ^\d{3}$. If you add your own anchors like pattern="^\d{3}$", it effectively becomes ^^\d{3}$$, which still works but is redundant. Be aware of this to avoid confusion when debugging.

Best Practices

  1. Always validate on the server too. Client-side validation is a UX feature, not a security feature. Every validation rule that matters must be duplicated on the server. Never trust the client.

  2. Use novalidate and control the experience. Browser default validation popups are unstyled, inconsistent across browsers, and inaccessible in some cases. Take control by adding novalidate and building your own error display.

  3. Validate on blur, re-validate on input. This pattern gives users time to finish typing before showing errors, but provides instant feedback when they are fixing a mistake. It is the best balance of helpfulness and non-intrusiveness.

  4. Debounce expensive validations. Any validation that hits a server endpoint or performs heavy computation should be debounced. 300-500ms is the sweet spot.

  5. Focus the first invalid field on submit. When a user submits and there are errors, programmatically focus the first invalid field. This is especially important for accessibility — screen reader users need to be directed to the problem.

  6. Keep error messages specific and actionable. "Invalid input" is useless. "Password must be at least 8 characters. You entered 5." tells the user exactly what to fix. Include the constraint values and, when possible, what the user actually entered.

  7. Test with keyboard and screen readers. Tab through your entire form. Ensure errors are announced. Ensure focus management works. Use aria-invalid, aria-describedby, and live regions. Run your form through the NVDA or VoiceOver screen reader at least once.

  8. Do not disable the submit button preemptively. Some developers disable the submit button until all fields are valid. This creates a terrible experience for users who cannot figure out what is wrong. Instead, let them submit and show clear errors.

References

Powered by Contentful