splitforms.com
All articles/ TUTORIALS14 MIN READPublished June 18, 2026

How to Validate an HTML Form with JavaScript (2026 Tutorial)

Step-by-step tutorial on validating HTML forms with JavaScript in 2026. Covers HTML5 constraints, the Constraint Validation API, custom error messages, real-time validation, cross-field checks, and regex patterns.

✶ Written by
splitforms.com / blog

Founder of splitforms — the form backend API for developers. Writes about form UX, anti-spam, and shipping web apps without backend code.

Why you need client-side validation

Client-side validation serves four purposes. First, it prevents unnecessary server requests — if the user forgets the email field, the form catches it locally instead of sending a POST that the server has to reject. Second, it gives instant feedback. The user sees the error in the same second they blur the field, not after a two-second round-trip. Third, it reduces spam submissions — bots that fire HTTP clients at your endpoint often skip JavaScript entirely, so any validation gated behind a JS check won't help against bots specifically, but the presence of JS-driven honeypot checks and timing gates does filter out the dumber ones. Fourth, it produces a better overall UX: inline error messages, disabled submit buttons, and real-time state changes all make the form feel responsive and professional.

But here is the rule that never changes: always validate server-side too. A user can disable JavaScript, modify the HTML in DevTools, or POST directly to your endpoint with a curl command. Client-side validation is a convenience for honest users, not a security boundary. Think of it as the front door lock — it stops casual attempts, but the deadbolt (server-side validation) is what actually keeps the house secure.

The approach in this tutorial is layered. Start with HTML5 attributes for the basic rules, then add JavaScript for everything HTML5 can't do on its own: custom error messages, cross-field validation, and real-time feedback. Every step builds on the last one, and by the end you'll have a complete, production-ready validation system.

Step 1: Start with HTML5 constraints

The browser already knows how to validate common fields. You don't need to write regex or comparison functions for things like "is this a required field" or "is this an email address" — the browser does it natively through a handful of attributes. Here's a contact form with every standard constraint applied:

<form id="contact">
  <label for="name">Name</label>
  <input
    id="name"
    name="name"
    type="text"
    required
    minlength="2"
    maxlength="80"
    placeholder="Ada Lovelace"
  />

  <label for="email">Email</label>
  <input
    id="email"
    name="email"
    type="email"
    required
    placeholder="ada@example.com"
  />

  <label for="phone">Phone</label>
  <input
    id="phone"
    name="phone"
    type="tel"
    pattern="\+?[1-9][\d\s\-]{6,14}"
    placeholder="+1 555 123 4567"
  />

  <label for="subject">Subject</label>
  <select id="subject" name="subject" required>
    <option value="">Choose a topic...</option>
    <option value="general">General inquiry</option>
    <option value="support">Technical support</option>
    <option value="partnership">Partnership</option>
    <option value="other">Other</option>
  </select>

  <label for="message">Message</label>
  <textarea
    id="message"
    name="message"
    required
    minlength="20"
    rows="5"
    placeholder="Tell us about your project..."
  ></textarea>

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

Each attribute maps to a specific validity rule the browser enforces automatically. required blocks empty submits. type="email" checks that the value contains an @ and a domain-like string. minlength and maxlength set character bounds. pattern runs a regex match (the browser adds ^ and $ anchors for you, so don't include them). The required attribute on the <select> ensures the user picks something other than the placeholder option (which has an empty value).

These constraints work without any JavaScript. The browser will refuse to submit the form and show a native tooltip when the user clicks Submit on an invalid form. That's the baseline. The problem is: the browser's default tooltips are ugly, hard to style, and show generic messages ("Please fill out this field" instead of "Your name must be at least 2 characters"). JavaScript lets you fix all three problems.

Behind the scenes, every constrained input gets a validity object with granular flags. For the name field above with required minlength="2", the browser sets:

  • validity.valueMissing — true when the field is empty.
  • validity.tooShort — true when the value is under 2 characters.
  • validity.tooLong — true when the value exceeds 80 characters.
  • validity.valid — false when any of the above is true.

The email field gets validity.typeMismatch instead of tooShort. The phone field gets validity.patternMismatch. These flags are the bridge between HTML5 attributes and JavaScript logic — everything in Steps 2 through 6 reads from them.

Step 2: Access the Constraint Validation API

The Constraint Validation API is the programmatic interface to everything the browser knows about an input's validity state. Three objects/methods are the core of the API:

const nameInput  = document.getElementById("name");
const form      = document.getElementById("contact");

// Check a single input's validity state.
console.log(nameInput.validity.valid);          // true or false
console.log(nameInput.validity.valueMissing);   // true if empty + required
console.log(nameInput.validity.tooShort);       // true if under minlength

// Check the entire form at once.
console.log(form.checkValidity());              // true if ALL fields pass

// The browser's default error message for this field.
console.log(nameInput.validationMessage);       // "Please fill out this field"

// Trigger native error tooltips on every invalid field.
form.reportValidity();

input.validity is an object with boolean flags for every possible validation failure. The full list: valueMissing, typeMismatch, patternMismatch, tooShort, tooLong, rangeUnderflow, rangeOverflow, stepMismatch, badInput, and customError. The valid property is false when any flag is true. You can read whichever flag is relevant and write a custom message based on it.

form.checkValidity() iterates over every input in the form and returns true only if every single one passes. It's the equivalent of asking "is this entire form valid right now?" without any side effects — no tooltips, no focus changes, no scrolling. form.reportValidity()does the same check but also shows the browser's native tooltips and focuses the first invalid field, which is useful if you want to let the browser handle the error display at submit time.

input.validationMessage returns the browser's built-in error string. It's the text that appears in the native tooltip. You can override it with setCustomValidity(), which is what the next step covers.

Step 3: Show custom error messages with JavaScript

The browser's default error messages are generic and often unhelpful. setCustomValidity(message) lets you override them. Combined with a sibling <p>element that you show or hide based on the input's validity state, you get fully custom error messages that match your brand voice and design system.

<label>
  <span>Name</span>
  <input id="name" name="name" type="text" required minlength="2" />
  <p id="name-error" class="error"></p>
</label>
const nameInput = document.getElementById("name");
const nameError = document.getElementById("name-error");

function validateName() {
  if (nameInput.validity.valueMissing) {
    nameError.textContent = "Your name is required.";
  } else if (nameInput.validity.tooShort) {
    nameError.textContent = `Name must be at least ${nameInput.minLength} characters (currently ${nameInput.value.length}).`;
  } else if (nameInput.validity.tooLong) {
    nameError.textContent = `Name must be under ${nameInput.maxLength} characters.`;
  } else {
    nameError.textContent = "";
  }
}

// Validate when the user leaves the field.
nameInput.addEventListener("blur", validateName);

// Clear the error once the user starts fixing it.
nameInput.addEventListener("input", () => {
  if (nameInput.validity.valid) {
    nameError.textContent = "";
  }
});

// Override the browser tooltip so it uses your custom message.
nameInput.addEventListener("invalid", () => {
  validateName();
  nameInput.setCustomValidity(nameInput.textContent || " ");
});
nameInput.addEventListener("input", () => {
  nameInput.setCustomValidity(""); // clear sticky custom message
});

Three things to understand about this pattern. First, the validateName() function reads the validity flags in priority order — it checks valueMissing before tooShort because only one error can display at a time and missing-value is the more fundamental problem. Second, the blur event fires when the user tabs away from the field, which is the right moment to show errors — not on every keystroke (that's annoying) and not only on submit (that's too late). Third, the input event clears the error message as soon as the field becomes valid, which gives immediate positive feedback.

The setCustomValidity() call is sticky: once you set it, the input stays "custom invalid" until you clear it with an empty string. That's why the input listener clears it — without that, the user could fix the error and the field would still report as invalid. The invalid listener sets the custom message so that reportValidity() (called on form submit) shows your message in the browser tooltip instead of the generic default.

The CSS for the error paragraph is minimal:

.error:empty {
  display: none; /* hide when there's no error text */
}
.error {
  color: #dc2626;
  font-size: 0.8125rem;
  margin-top: 4px;
}

The :empty pseudo-class is the key trick: when textContent is "", the element is empty and the CSS hides it. When validation writes a message, the element is no longer empty and it appears. No class toggling needed.

Step 4: Real-time validation on input

Real-time validation means validating as the user types, not just on blur or submit. The trick is using the right event for the right moment: input fires on every keystroke (for immediate feedback when fixing errors), blur fires when the user leaves the field (for showing errors after first interaction), and submit fires when the user clicks the submit button (for catching any fields they never touched).

Here's a complete working contact form with real-time validation on every field:

<form id="contact" action="https://splitforms.com/api/submit" method="POST">
  <input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />

  <label>
    <span>Name</span>
    <input
      id="name"
      name="name"
      type="text"
      required
      minlength="2"
      maxlength="80"
      aria-describedby="name-error"
    />
    <p id="name-error" class="error" aria-live="polite"></p>
  </label>

  <label>
    <span>Email</span>
    <input
      id="email"
      name="email"
      type="email"
      required
      placeholder="ada@example.com"
      aria-describedby="email-error"
    />
    <p id="email-error" class="error" aria-live="polite"></p>
  </label>

  <label>
    <span>Subject</span>
    <select
      id="subject"
      name="subject"
      required
      aria-describedby="subject-error"
    >
      <option value="">Choose a topic...</option>
      <option value="general">General inquiry</option>
      <option value="support">Technical support</option>
      <option value="partnership">Partnership</option>
      <option value="other">Other</option>
    </select>
    <p id="subject-error" class="error" aria-live="polite"></p>
  </label>

  <label>
    <span>Message</span>
    <textarea
      id="message"
      name="message"
      required
      minlength="20"
      rows="5"
      placeholder="Tell us about your project..."
      aria-describedby="message-error"
    ></textarea>
    <p id="message-error" class="error" aria-live="polite"></p>
  </label>

  <button type="submit">Send message</button>
</form>
const form = document.getElementById("contact");

const validators = {
  name(input, errorEl) {
    if (input.validity.valueMissing) {
      errorEl.textContent = "Your name is required.";
    } else if (input.validity.tooShort) {
      errorEl.textContent = `At least ${input.minLength} characters, please.`;
    } else if (input.validity.tooLong) {
      errorEl.textContent = `Keep it under ${input.maxLength} characters.`;
    } else {
      errorEl.textContent = "";
    }
  },
  email(input, errorEl) {
    if (input.validity.valueMissing) {
      errorEl.textContent = "We need your email to reply.";
    } else if (input.validity.typeMismatch) {
      errorEl.textContent = "That doesn't look like a valid email.";
    } else {
      errorEl.textContent = "";
    }
  },
  subject(input, errorEl) {
    if (input.validity.valueMissing) {
      errorEl.textContent = "Please choose a subject.";
    } else {
      errorEl.textContent = "";
    }
  },
  message(input, errorEl) {
    if (input.validity.valueMissing) {
      errorEl.textContent = "A message is required.";
    } else if (input.validity.tooShort) {
      errorEl.textContent = `Please write at least ${input.minLength} characters (currently ${input.value.length}).`;
    } else {
      errorEl.textContent = "";
    }
  },
};

// Attach blur + input listeners to every field.
for (const [fieldName, validate] of Object.entries(validators)) {
  const input = document.getElementById(fieldName);
  const errorEl = document.getElementById(`${fieldName}-error`);

  // Show errors after first interaction (blur).
  input.addEventListener("blur", () => validate(input, errorEl));

  // Clear errors on fix (input).
  input.addEventListener("input", () => {
    if (input.validity.valid) {
      errorEl.textContent = "";
    }
  });
}

// On submit: validate everything, then let the form through.
form.addEventListener("submit", (e) => {
  let allValid = true;

  for (const [fieldName, validate] of Object.entries(validators)) {
    const input = document.getElementById(fieldName);
    const errorEl = document.getElementById(`${fieldName}-error`);
    validate(input, errorEl);
    if (!input.validity.valid) {
      allValid = false;
    }
  }

  if (!allValid) {
    e.preventDefault();
    // Focus the first invalid field.
    form.querySelector(":invalid")?.focus();
  }
  // If allValid, the form submits normally to the action URL.
});

The validators object maps each field name to a function that checks the relevant validity flags and writes the error message. This pattern scales cleanly: add a new field, add a new validator function, and the loop wires everything up. The blur event shows errors after the user interacts with a field for the first time. The input event clears errors immediately when the user fixes the problem. The submit event catches any fields the user never touched and focuses the first invalid one.

If you want to submit via fetch instead of a full page reload (common in single-page apps), replace the submit handler's fallthrough with a fetch call:

form.addEventListener("submit", async (e) => {
  // ... validation logic from above ...
  if (!allValid) { e.preventDefault(); return; }

  e.preventDefault(); // stay on the page
  const data = new FormData(form);
  const res = await fetch(form.action, {
    method: "POST",
    body: data,
  });

  if (res.ok) {
    form.reset();
    // Clear all error messages.
    document.querySelectorAll(".error").forEach((el) => {
      el.textContent = "";
    });
  }
});

Step 5: Cross-field validation

HTML5 attributes can only check a single field in isolation. Cross-field validation — where one field's validity depends on another field's value — requires JavaScript. Two common cases are confirm-email matching and conditional rules.

Confirm email.The second email field must match the first. Here's how:

<label>
  <span>Email</span>
  <input id="email" name="email" type="email" required />
  <p id="email-error" class="error"></p>
</label>

<label>
  <span>Confirm email</span>
  <input
    id="confirm-email"
    name="confirm_email"
    type="email"
    required
    aria-describedby="confirm-email-error"
  />
  <p id="confirm-email-error" class="error"></p>
</label>
const email      = document.getElementById("email");
const confirmEl  = document.getElementById("confirm-email");
const confirmErr = document.getElementById("confirm-email-error");

function validateConfirmEmail() {
  if (confirmEl.validity.valueMissing) {
    confirmErr.textContent = "Please confirm your email.";
  } else if (confirmEl.value !== email.value) {
    confirmEl.setCustomValidity("Emails do not match.");
    confirmErr.textContent = "Emails do not match.";
  } else {
    confirmEl.setCustomValidity("");
    confirmErr.textContent = "";
  }
}

confirmEl.addEventListener("blur", validateConfirmEmail);
confirmEl.addEventListener("input", validateConfirmEmail);
email.addEventListener("input", validateConfirmEmail);

The key line is confirmEl.setCustomValidity("Emails do not match."). This is the only way to make the validity.valid property reflect a cross-field check. Without setCustomValidity, the browser thinks the confirm-email field is valid because it passes all its own HTML5 constraints (it's a non-empty email-shaped string). Setting a custom error message tells the browser "this field is invalid for a reason the HTML attributes can't express," which makes valid return false and prevents form submission.

Note the email.addEventListener("input", validateConfirmEmail) at the bottom. This re-validates the confirm field whenever the user changes the original email, which catches the case where they type a matching confirm, then change the first email.

Conditional rules.A password's minimum length can depend on the account type:

<select id="account-type" name="account_type" required>
  <option value="">Choose account type...</option>
  <option value="personal">Personal</option>
  <option value="business">Business</option>
</select>

<label>
  <span>Password</span>
  <input
    id="password"
    name="password"
    type="password"
    required
    minlength="8"
    aria-describedby="password-error"
  />
  <p id="password-error" class="error"></p>
</label>
const accountType = document.getElementById("account-type");
const password    = document.getElementById("password");
const passwordErr = document.getElementById("password-error");

function getMinLength() {
  return accountType.value === "business" ? 12 : 8;
}

function validatePassword() {
  const min = getMinLength();

  if (password.validity.valueMissing) {
    passwordErr.textContent = "Password is required.";
  } else if (password.value.length > 0 && password.value.length < min) {
      password.setCustomValidity(
        (accountType.value === "business"
          ? "Business accounts"
          : "Personal accounts")
        + " need at least " + min + " characters."
      );
    passwordErr.textContent = password.validationMessage;
  } else {
    password.setCustomValidity("");
    passwordErr.textContent = "";
  }
}

password.addEventListener("input", validatePassword);
password.addEventListener("blur", validatePassword);
accountType.addEventListener("change", validatePassword);

The change event on the select re-validates the password when the user switches account types. If they entered 8 characters for personal, then switch to business, the validation catches it immediately.

Step 6: Disable submit until valid

Some forms disable the submit button until every field passes validation. The pattern uses the form's change event (which fires on any input change inside the form) combined with form.checkValidity():

const form   = document.getElementById("contact");
const submit = document.querySelector("button[type='submit']");

function updateSubmitState() {
  submit.disabled = !form.checkValidity();
}

// Check on every change inside the form.
form.addEventListener("input", updateSubmitState);
form.addEventListener("change", updateSubmitState);

// Set initial state.
updateSubmitState();

The button starts disabled and stays disabled until every constrained field passes. The input event covers text inputs and textareas. The change event covers selects, checkboxes, and radios. Both are needed because they fire on different elements.

One gotcha: form.checkValidity() also fires the invalid event on every invalid field the first time it's called, which can trigger unwanted side effects if you have invalidlisteners. If that's a problem, iterate the inputs manually instead:

function isFormValid() {
  const inputs = form.querySelectorAll("input, select, textarea");
  return Array.from(inputs).every((el) => el.validity.valid);
}

This reads the valid property without triggering any events. Use it when you need a silent validity check.

Whether to disable the submit button is a UX choice. Some teams prefer it because it prevents the frustrating "click submit, see nothing happen" moment. Others prefer to leave the button enabled and show all errors at once on submit attempt, so the user can see everything they need to fix at a glance. Both patterns are valid — pick the one that fits your form's complexity and your users' expectations.

Regex patterns for common fields

The pattern attribute accepts a JavaScript-compatible regex (without ^ and $anchors — the browser adds them automatically). Here are battle-tested patterns for the most common form fields:

FieldPattern
Email (strict, requires TLD)[^@\s]+@[^@\s]+\.[^@\s]+
Phone (international)\+?[1-9][\d\s\-]{6,14}
URLhttps?://[^\s]+\.[^\s]+
US ZIP code\d{5}(-\d{4})?
Credit card (all major)\d{4}[\s\-]?\d{4}[\s\-]?\d{4}[\s\-]?\d{4}
Strong password (8+ chars, mixed)(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}
IPv4 address\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}
Hex color#([0-9a-fA-F]{3}){1,2}

A few notes. The email pattern enforces a TLD — the native type="email" accepts foo@barwithout a dot, which is technically valid per the spec but almost never intended. The phone pattern is deliberately loose because phone formats vary wildly by country — tighten it for a specific country. The credit card pattern checks digit groups, not a Luhn checksum — for real card validation, use the Luhn algorithm in JavaScript on top of this pattern. The strong password pattern uses lookaheads to require at least one lowercase letter, one uppercase letter, and one digit.

To use any of these in an input, add the pattern attribute and optionally a title attribute for the tooltip message:

<input
  type="tel"
  name="phone"
  pattern="\+?[1-9][\d\s\-]{6,14}"
  title="Enter a valid phone number"
/>

FAQ

Should I validate with HTML5 attributes or JavaScript?

Use both. HTML5 attributes (required, type, pattern, minlength) handle the basic rules for free with zero JavaScript. Layer JavaScript on top for custom error messages, cross-field checks (confirm email, password strength that depends on account type), and real-time feedback as the user types. HTML5 is the foundation; JavaScript is the polish.

Does JavaScript validation replace server-side validation?

No. JavaScript validation is for UX only — it prevents bad data from reaching your server, but a user (or a bot) can disable JavaScript, modify the HTML, or POST directly to your endpoint. Always validate on the server. Treat client-side JS as a convenience for honest users, not a security boundary.

How do I validate email format in JavaScript?

Two options. The simplest is to let the browser do it — type="email" on the input, then read input.validity.typeMismatch in your JS. If you need stricter validation (requiring a TLD, for example), add a pattern attribute like pattern="[^@\s]+@[^@\s]+\.[^@\s]+" or use a regex in JavaScript. The Constraint Validation API integrates with both approaches.

Can I use this with React/Vue/Svelte?

Yes. The Constraint Validation API is built into every browser and is framework-agnostic. In React, use refs to access the native input elements. In Vue, use template refs. In Svelte, use bind:this. The validation logic (checkValidity, setCustomValidity, validity state) is the same regardless of framework — the only thing that changes is how you reference the DOM node.

How do I show error messages below each field?

Add an empty <p> element (or a <span>) right after the input, give it an id that matches aria-describedby on the input, and write the error text into it based on the input's validity state. Show it when the field is invalid and the user has interacted (on blur or on submit attempt). Hide it when the field becomes valid. The pattern is shown in Step 3 and Step 4 of this tutorial.

Should I disable the submit button until the form is valid?

It's optional. Disabling the button prevents accidental submission but hides all errors until the user finishes filling every field — which can feel frustrating if they don't know what's wrong. An alternative is to leave the button enabled and call reportValidity() on submit, which shows all browser tooltips at once. Pick the pattern that fits your UX goals. The disable-until-valid pattern is covered in Step 6.

What's the difference between checkValidity() and reportValidity()?

checkValidity() returns true or false — it runs all the constraint checks on the form (or a single input) silently. reportValidity() does the same thing but also triggers the browser's native error tooltips on any invalid fields and focuses the first one. Use checkValidity when you want to handle errors yourself in JavaScript. Use reportValidity when you're okay letting the browser show its built-in messages.

About the author
✻ ✻ ✻

Get your free contact form API key in 60 seconds.

500 free form submissions per month. No credit card. No SDK, no PHP, no plugin. Drop one POST endpoint in your form and submissions land in your dashboard. Starter adds inbox delivery.

Generate access key →Read the docs
founders pricing locked in · early access open