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.
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 popupsreportValidity()it can show native messages (useful in simple cases)validitydisplays 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 HTML & CSS, JavaScript
Be the first to comment