How to create an HTML form with client-side validation in JavaScript

A well-made form is not just “fields + send”: it must guide the user, prevent errors, and validate data clearly. In this guide, we create an HTML form with client-side validation in JavaScript using the Constraint Validation API, custom messages, and good UX and accessibility practices, with code ready to copy.

Programmazione JavaScript
Programmazione JavaScript

Client-side validation is used to improve the user experience: it immediately signals errors, reduces unnecessary submissions, and makes the form more “usable”. However, it is not a security measure: every check must still be repeated server-side. That said, well-done client-side validation is one of those details that truly changes conversions (and reduces “dirty” contacts).

In this guide, we’ll look at how to create a modern HTML form with client-side validation in JavaScript, using the browser’s native tools (Constraint Validation API) and adding custom messages, error handling, accessibility, and clean UX.

What does “client-side validation” mean

Client-side validation means verifying data before the form is sent to the server. Examples:

  • empty or invalid email field
  • password too short
  • checkbox “I accept the privacy policy” not checked
  • incorrectly formatted phone number

The best way to do it is to combine:

  • HTML5 validation (required, type, minlength, pattern, etc.)
  • JavaScript to handle UI, custom messages, and extra logic

Golden rule: client-side UX, server-side security

Remember: the user can disable JS, modify the HTML, or send requests directly via API. Therefore:

  • client-side validation is for UX and data quality
  • server-side validation is for security and integrity

Complete example: form with validation and custom messages

Below is a ready-to-use example: name, email, phone, message, and privacy consent. We are using:

  • Constraint Validation API (checkValidity, reportValidity, validity)
  • error messages for individual fields
  • CSS classes for valid/invalid states
  • accessibility with aria-describedby and aria-live
<form id="contactForm" novalidate>
  <div class="field">
    <label for="name">Nome e cognome</label>
    <input id="name" name="name" type="text" required minlength="2"
           autocomplete="name" aria-describedby="nameError">
    <p id="nameError" class="error" aria-live="polite"></p>
  </div>

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

  <div class="field">
    <label for="phone">Telefono (opzionale)</label>
    <input id="phone" name="phone" type="tel"
           inputmode="tel"
           pattern="^+?[0-9s-]{7,20}$"
           placeholder="+39 348 123 4567"
           autocomplete="tel" aria-describedby="phoneHelp phoneError">
    <small id="phoneHelp" class="help">Formato ammesso: numeri, spazi e trattini.</small>
    <p id="phoneError" class="error" aria-live="polite"></p>
  </div>

  <div class="field">
    <label for="message">Messaggio</label>
    <textarea id="message" name="message" required minlength="10" rows="5"
              aria-describedby="messageError"></textarea>
    <p id="messageError" class="error" aria-live="polite"></p>
  </div>

  <div class="field">
    <label class="checkbox">
      <input id="privacy" name="privacy" type="checkbox" required aria-describedby="privacyError">
      Ho letto e accetto l’informativa privacy
    </label>
    <p id="privacyError" class="error" aria-live="polite"></p>
  </div>

  <button type="submit">Invia</button>

  <p id="formStatus" class="status" aria-live="polite"></p>
</form>

Minimal styles (optional) to make states clear:

.field { margin-bottom: 14px; }
label { display:block; margin-bottom: 6px; font-weight: 600; }
input, textarea { width: 100%; padding: 10px; border: 1px solid #ccc; border-radius: 8px; }
input.is-invalid, textarea.is-invalid { border-color: #c0392b; }
input.is-valid, textarea.is-valid { border-color: #27ae60; }
.error { margin: 6px 0 0; color: #c0392b; font-size: 0.95rem; }
.help { display:block; margin-top: 6px; color:#666; font-size: 0.9rem; }
.status { margin-top: 12px; }

And now for the JavaScript part: validation on submit + “live” validation during typing.

(() => {
  const form = document.getElementById('contactForm');
  const statusEl = document.getElementById('formStatus');

  // Mappa campo -> elemento errori
  const errorMap = new Map([
    ['name', document.getElementById('nameError')],
    ['email', document.getElementById('emailError')],
    ['phone', document.getElementById('phoneError')],
    ['message', document.getElementById('messageError')],
    ['privacy', document.getElementById('privacyError')],
  ]);

  const getErrorMessage = (input) => {
    const v = input.validity;

    if (v.valueMissing) {
      // Messaggi specifici per campo
      if (input.name === 'privacy') return 'Devi accettare l’informativa privacy per proseguire.';
      return 'Questo campo è obbligatorio.';
    }
    if (v.typeMismatch) {
      if (input.type === 'email') return 'Inserisci un indirizzo email valido.';
      return 'Formato non valido.';
    }
    if (v.tooShort) {
      return `Inserisci almeno ${input.minLength} caratteri.`;
    }
    if (v.patternMismatch) {
      if (input.name === 'phone') return 'Inserisci un numero valido (numeri, spazi e trattini).';
      return 'Formato non valido.';
    }
    if (v.badInput) return 'Valore non valido.';
    return '';
  };

  const setFieldState = (input, isValid, message = '') => {
    const errorEl = errorMap.get(input.name);
    if (errorEl) errorEl.textContent = message;

    input.classList.toggle('is-invalid', !isValid);
    input.classList.toggle('is-valid', isValid);

    // Per accessibilitĂ : aria-invalid quando non valido
    input.setAttribute('aria-invalid', String(!isValid));
  };

  const validateField = (input) => {
    // telefono opzionale: se vuoto, consideralo valido
    if (input.name === 'phone' && input.value.trim() === '') {
      setFieldState(input, true, '');
      return true;
    }

    const ok = input.checkValidity();
    const msg = ok ? '' : getErrorMessage(input);
    setFieldState(input, ok, msg);
    return ok;
  };

  const focusFirstInvalid = () => {
    const firstInvalid = form.querySelector('.is-invalid');
    if (firstInvalid) firstInvalid.focus({ preventScroll: false });
  };

  // Validazione live: al blur e all'input
  form.addEventListener('blur', (e) => {
    const target = e.target;
    if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
      validateField(target);
    }
  }, true);

  // Debounce semplice per non validare a ogni tasto in modo aggressivo
  let t = null;
  form.addEventListener('input', (e) => {
    const target = e.target;
    if (!(target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)) return;

    clearTimeout(t);
    t = setTimeout(() => validateField(target), 200);
  });

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

    const inputs = Array.from(form.querySelectorAll('input, textarea'));
    const allOk = inputs.every(validateField);

    if (!allOk) {
      statusEl.textContent = 'Controlla i campi evidenziati.';
      focusFirstInvalid();
      return;
    }

    // Qui invieresti i dati al server (fetch/AJAX) oppure lasci fare al form
    // Esempio placeholder:
    statusEl.textContent = 'Invio in corso...';

    try {
      // Simulazione invio
      await new Promise((r) => setTimeout(r, 400));
      statusEl.textContent = 'Messaggio inviato correttamente.';
      form.reset();

      // Pulisci stati visivi dopo reset
      inputs.forEach((i) => {
        i.classList.remove('is-valid', 'is-invalid');
        i.removeAttribute('aria-invalid');
        const err = errorMap.get(i.name);
        if (err) err.textContent = '';
      });
    } catch (err) {
      statusEl.textContent = 'Errore durante l’invio. Riprova.';
    }
  });
})();

Why use the Constraint Validation API

Many people reinvent the wheel with regex everywhere and manual checks. In reality, modern browsers offer a robust system already in place:

  • checkValidity() it checks field validity without showing browser popups
  • reportValidity() it can show native messages (useful in simple cases)
  • validity displays the detailed status (valueMissing, tooShort, typeMismatch, patternMismatch…)

This way you focus on the UX (messages, highlighting, accessibility), without duplicating useless logic.

“Gentle” validation: when to show the error

An error shown too early can be annoying. Practical guidelines:

  • show errors on submit(always)
  • show errors on blur(when the user leaves the field)
  • validate “live” while typing only with debounce and only if the field has already been touched (more advanced approach)

In the example, on submit we validate everything; during completion, we validate on blur and with a light debounce.

Accessibility: it’s not optional

Two considerations make the difference:

  • link the error message to the field with aria-describedby
  • update the status with aria-invalidwhen needed

Additionally, using aria-live="polite" on the error message allows screen readers to communicate the change.

Quick FAQs

Do I have to use novalidate in the form?

If you want to manage messages and UI yourself, yes: novalidate disables the browser’s native popups and allows you a consistent UX. If you are happy with standard HTML5 validation, you might not need it.

Is the phone regex mandatory?

No. Phone numbers are complex (different formats). It’s better to have permissive client-side validation and server-side normalization, or dedicated libraries when high precision is needed.

Can I submit with fetch without reloading the page?

Yes. In the submit block, you’ll find a place to insert fetch(). Remember: always server-side validation, and clear error handling and messages on the client-side.

Conclusion

A module with client-side validation in JavaScript doesn’t have to be complicated: HTML5 already gives you solid rules, JavaScript allows you to make them clearer, more consistent, and more accessible. If you build useful messages, highlight errors the right way, and don’t forget accessibility, you’ll get faster forms, less frustration, and better quality data.

Pubblicato in ,

Se vuoi rimanere aggiornato su How to create an HTML form with client-side validation in JavaScript iscriviti alla nostra newsletter settimanale

Be the first to comment

Leave a Reply

Your email address will not be published.


*