DEV Community

Weather Clock Dash
Weather Clock Dash

Posted on

HTML5 Form Validation: Native API, Custom Messages, and Accessible Errors

HTML5 form validation is built into the browser and works without any JavaScript. But combining native validation with JavaScript gives you full control over messages, async validation, and accessible error states. Firefox has robust support for the Constraint Validation API.

Native HTML Validation

HTML attributes handle common validation rules:

<form id="signup">
  <!-- Required field -->
  <input type="text" name="name" required>

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

  <!-- Length constraints -->
  <input type="password" name="password" 
         minlength="8" maxlength="128" required>

  <!-- Number range -->
  <input type="number" name="age" min="13" max="120">

  <!-- Pattern matching (regex) -->
  <input type="text" name="username"
         pattern="[a-zA-Z0-9_]{3,20}"
         title="Letters, numbers, and underscores, 3-20 characters"
         required>

  <!-- URL format -->
  <input type="url" name="website" placeholder="https://...">

  <!-- Date range -->
  <input type="date" name="birthday" 
         max="2010-01-01" min="1900-01-01">

  <button type="submit">Create account</button>
</form>
Enter fullscreen mode Exit fullscreen mode

The browser validates automatically on submit and shows native error messages. In Firefox, these appear as styled tooltips below the field.

The Constraint Validation API

JavaScript access to validation state:

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

// Validate a specific element
input.validity.valid        // true if passes all constraints
input.validity.valueMissing // true if required and empty
input.validity.typeMismatch // true if type=email but not an email
input.validity.tooShort     // true if shorter than minlength
input.validity.tooLong      // true if longer than maxlength
input.validity.rangeUnderflow // true if less than min
input.validity.rangeOverflow  // true if greater than max
input.validity.patternMismatch // true if doesn't match pattern
input.validity.customError  // true if setCustomValidity called

// Validation message
input.validationMessage     // "Please fill out this field." etc.

// Check if a form is valid
const form = document.getElementById('signup');
form.checkValidity()        // returns boolean
form.reportValidity()       // returns boolean AND shows native UI
Enter fullscreen mode Exit fullscreen mode

Custom Validation Messages

Override the browser's default messages:

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

passwordInput.addEventListener('input', () => {
  const value = passwordInput.value;

  if (value.length < 8) {
    passwordInput.setCustomValidity('Password must be at least 8 characters.');
  } else if (!/[A-Z]/.test(value)) {
    passwordInput.setCustomValidity('Password must contain at least one uppercase letter.');
  } else if (!/[0-9]/.test(value)) {
    passwordInput.setCustomValidity('Password must contain at least one number.');
  } else {
    passwordInput.setCustomValidity(''); // Clear custom error (valid)
  }
});
Enter fullscreen mode Exit fullscreen mode

Important: setCustomValidity('') clears the custom error. If you don't clear it, the field stays invalid even when the user fixes it.

Preventing Default Submission and Showing Custom UI

Don't use the browser's native error UI — build your own for better UX:

const form = document.getElementById('signup');

form.addEventListener('submit', async (e) => {
  e.preventDefault();

  // Reset previous errors
  document.querySelectorAll('.error-message').forEach(el => el.remove());
  document.querySelectorAll('.field-error').forEach(el => el.classList.remove('field-error'));

  // Check validity
  if (!form.checkValidity()) {
    // Show errors for each invalid field
    for (const field of form.elements) {
      if (!field.validity.valid) {
        showError(field, field.validationMessage);
      }
    }
    return;
  }

  // Async validation (server-side check)
  const emailField = form.querySelector('[name="email"]');
  const taken = await checkEmailTaken(emailField.value);
  if (taken) {
    showError(emailField, 'This email is already registered.');
    return;
  }

  // Submit
  submitForm(new FormData(form));
});

function showError(field, message) {
  field.classList.add('field-error');

  const errorEl = document.createElement('p');
  errorEl.className = 'error-message';
  errorEl.textContent = message;
  errorEl.setAttribute('role', 'alert'); // Announces to screen readers

  field.insertAdjacentElement('afterend', errorEl);
}
Enter fullscreen mode Exit fullscreen mode
.field-error {
  border-color: #EF4444;
  outline-color: #EF4444;
}

.error-message {
  color: #DC2626;
  font-size: 0.875rem;
  margin-top: 4px;
}
Enter fullscreen mode Exit fullscreen mode

Real-Time Validation

Validate as the user types, but only after first interaction:

const emailField = document.querySelector('[name="email"]');
let hasInteracted = false;

emailField.addEventListener('blur', () => {
  hasInteracted = true;
  validateField(emailField);
});

emailField.addEventListener('input', () => {
  if (hasInteracted) validateField(emailField);
});

function validateField(field) {
  // Remove existing error
  const existingError = field.parentNode.querySelector('.error-message');
  existingError?.remove();
  field.classList.remove('field-error');

  if (!field.validity.valid) {
    showError(field, field.validationMessage);
  }
}
Enter fullscreen mode Exit fullscreen mode

Accessible Validation

Link error messages to their fields with aria-describedby:

<div class="field-group">
  <label for="email">Email</label>
  <input type="email" id="email" name="email" 
         required aria-describedby="email-error">
  <p id="email-error" class="error-message" role="alert" hidden>
    Please enter a valid email address.
  </p>
</div>
Enter fullscreen mode Exit fullscreen mode
function showError(field, message) {
  const errorId = `${field.id}-error`;
  let errorEl = document.getElementById(errorId);

  if (!errorEl) {
    errorEl = document.createElement('p');
    errorEl.id = errorId;
    errorEl.className = 'error-message';
    errorEl.setAttribute('role', 'alert');
    field.setAttribute('aria-describedby', errorId);
    field.insertAdjacentElement('afterend', errorEl);
  }

  errorEl.textContent = message;
  errorEl.hidden = false;
  field.setAttribute('aria-invalid', 'true');
}

function clearError(field) {
  const errorId = `${field.id}-error`;
  const errorEl = document.getElementById(errorId);
  if (errorEl) errorEl.hidden = true;
  field.removeAttribute('aria-invalid');
}
Enter fullscreen mode Exit fullscreen mode

novalidate + Custom Validation

Use novalidate on the form to disable browser's native validation UI while still using the Constraint Validation API for checkValidity():

<form id="signup" novalidate>
  <!-- novalidate prevents native error tooltips -->
  <!-- But checkValidity() still works -->
</form>
Enter fullscreen mode Exit fullscreen mode

This gives you full control over the error UI while keeping native constraint checking.

Firefox DevTools for Form Debugging

  • In the Inspector, look for :valid/:invalid pseudo-class states in element states
  • The Console shows form.checkValidity() results
  • Firefox respects pattern attribute in HTML5 forms and the DevTools Network panel shows the actual form data submitted

CSS targeting validation states:

input:valid { border-color: #10B981; }
input:invalid:not(:placeholder-shown) { border-color: #EF4444; }

/* :placeholder-shown trick: don't show invalid state on empty required fields */
/* before the user has interacted */
Enter fullscreen mode Exit fullscreen mode

Top comments (0)