Skip to content

JavaScript Tutorial Forms and Validation

Custom Validation and Error Messages

Lesson 125 of 258 2 min read By Deepak Paswan Updated Oct 4, 2026

The browser’s validation bubbles are not styleable and are the same for everyone. For full control, write your own, and keep it simple and accessible.

A complete example

index.html
HTML
<style>
  label { display: block; margin-top: 8px; }
  input { padding: 6px; }
  input[aria-invalid="true"] { border: 2px solid #c0392b; }
  .error { color: #c0392b; font-size: 14px; min-height: 18px; margin: 2px 0 0; }
</style>
<form id="signup" novalidate>
  <label for="email">Email</label>
  <input id="email" name="email" type="email" aria-describedby="email-error">
  <p id="email-error" class="error"></p>

  <label for="age">Age</label>
  <input id="age" name="age" type="number" aria-describedby="age-error">
  <p id="age-error" class="error"></p>

  <button type="submit">Sign up</button>
  <p id="result" role="status"></p>
</form>
<script>
  const form = document.querySelector("#signup");

  const rules = {
    email(value) {
      if (value === "") return "Enter your email address.";
      if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return "That does not look like an email address.";
      return "";
    },
    age(value) {
      if (value === "") return "Enter your age.";
      if (Number(value) < 18) return "You must be 18 or older.";
      return "";
    }
  };

  function validateField(input) {
    const message = rules[input.name](input.value.trim());
    document.querySelector("#" + input.id + "-error").textContent = message;
    input.setAttribute("aria-invalid", message ? "true" : "false");
    return message === "";
  }

  form.addEventListener("submit", event => {
    event.preventDefault();
    const fields = [...form.querySelectorAll("input")];
    const results = fields.map(validateField);        // validate every field, not just the first
    if (results.every(Boolean)) {
      document.querySelector("#result").textContent = "Account created!";
    } else {
      fields.find(f => f.getAttribute("aria-invalid") === "true").focus();
    }
  });

  // re-check a field when the user fixes it
  form.addEventListener("input", event => {
    if (event.target.getAttribute("aria-invalid") === "true") validateField(event.target);
  });
</script>

What this example does well

  • novalidate turns off the browser’s popup so the form shows its own messages.
  • One place for rules. rules maps each field name to a function that returns an error message, or "" when fine.
  • Errors in text next to the field, linked with aria-describedby, and aria-invalid for assistive technology.
  • Every field is validated on submit, so users see all problems at once.
  • Focus goes to the first invalid field.
  • Errors clear while typing, once the user starts fixing them.

When to validate

MomentGood for
On submitalways
On blur (leaving a field)formats, such as emails
On inputonly after an error was shown, or for live hints (password strength)

Do not show “required” errors while a user is still typing their first character.

Messages that help

“Invalid input” helps nobody. Say what to do: “Use at least 8 characters”, “Enter a date like 15/03/2026”.

lightbulb
Combine approaches
You can keep the browser rules (required, type="email") and read input.validity and input.validationMessage instead of writing every rule yourself. Use your own functions only for rules the browser cannot express.