Skip to content

Contact Form Validation and Success Feedback

Module 06 introduced form validation as a concept. This lesson builds the full, production-ready version with better UX: errors clear when the user starts correcting a field, email format is validated beyond a simple non-empty check, and the success state replaces the entire form rather than just appending a message.

  • Submitting the empty form shows inline errors under each required field
  • Errors clear automatically when the user starts typing in the problem field
  • Submitting with an email that lacks @ or . shows a specific format error
  • Message must be 500 characters or fewer — submitting over the limit shows an error
  • Submitting with all valid fields hides the form and shows a success message
  • The success message has a “Send another” button that restores the form

main.js runs on every page, so querying .contact-form at module scope returns null on pages that don’t have the form. All form-related element selections and listeners must go inside the if (contactForm) guard. Select elements first, then attach listeners inside the block:

const contactForm = document.querySelector('.contact-form');
const successMessage = document.querySelector('.form-success');
if (contactForm) {
const nameInput = contactForm.querySelector('#contact-name');
const emailInput = contactForm.querySelector('#contact-email');
const messageInput = contactForm.querySelector('#contact-message');
const nameError = contactForm.querySelector('#name-error');
const emailError = contactForm.querySelector('#email-error');
const messageError = contactForm.querySelector('#message-error');
// listeners go here
}

Expected HTML structure for each field (from contact.html):

<div class="form-group">
<label for="contact-name">Your name</label>
<input type="text" id="contact-name" name="name" required>
<span class="field-error" id="name-error" aria-live="polite"></span>
</div>

aria-live="polite" announces error messages to screen readers when they appear.

Inside the if (contactForm) block, read values fresh from the inputs on each submission:

contactForm.addEventListener('submit', (event) => {
event.preventDefault();
clearErrors();
const name = nameInput.value.trim();
const email = emailInput.value.trim();
const message = messageInput.value.trim();
let isValid = true;
if (!name) {
showError(nameError, 'Name is required.');
isValid = false;
}
if (!email) {
showError(emailError, 'Email is required.');
isValid = false;
} else if (!email.includes('@') || !email.includes('.')) {
showError(emailError, 'Enter a valid email address.');
isValid = false;
}
if (!message) {
showError(messageError, 'Message is required.');
isValid = false;
} else if (message.length > 500) {
showError(messageError, 'Message must be 500 characters or fewer.');
isValid = false;
}
if (!isValid) return;
contactForm.reset();
updateCharCount(0);
showSuccess();
});

contactForm.reset() clears all field values. updateCharCount(0) resets the character counter to zero — it is defined at module scope earlier in main.js alongside the char-count section. It must be module-scope (not inside an if block) to be callable here.

These three functions live at module scope so they are available across the contact form section:

function showError(errorEl, message) {
if (errorEl) {
errorEl.textContent = message;
errorEl.classList.add('visible');
}
}
function clearErrors() {
document.querySelectorAll('.field-error').forEach(el => {
el.textContent = '';
el.classList.remove('visible');
});
}
function showSuccess() {
if (contactForm) {
contactForm.style.display = 'none';
if (successMessage) {
successMessage.classList.add('visible');
}
}
}

clearErrors uses querySelectorAll('.field-error') rather than a hardcoded list — it clears every error span on the page without needing references to each one.

Inside the if (contactForm) block, each input handler checks whether the field is now valid before clearing the error. Errors only update if they are already visible — no premature errors appear while the user is filling out a fresh form.

.field-error has display: block in CSS at all times — errors are hidden by having empty textContent, not by the absence of the visible class. Clearing an error requires setting textContent = '' in addition to removing the class, matching what clearErrors() does.

nameInput.addEventListener('input', () => {
if (nameInput.value.trim()) {
nameError.textContent = '';
nameError.classList.remove('visible');
}
});
emailInput.addEventListener('input', () => {
const email = emailInput.value.trim();
if (email.includes('@') && email.includes('.')) {
emailError.textContent = '';
emailError.classList.remove('visible');
} else if (emailError.classList.contains('visible')) {
showError(emailError, email ? 'Enter a valid email address.' : 'Email is required.');
}
});
messageInput.addEventListener('input', () => {
const message = messageInput.value.trim();
if (message && message.length <= charLimit) {
messageError.textContent = '';
messageError.classList.remove('visible');
} else if (messageError.classList.contains('visible')) {
showError(messageError, message ? 'Message must be 500 characters or fewer.' : 'Message is required.');
}
});

The emailError.classList.contains('visible') guard on the else branches means the error message can update in real time (e.g., switching from “Email is required.” to “Enter a valid email address.” as the user types) without ever showing an error on a field the user has not yet submitted.

The success message HTML is already in contact.html:

<div class="form-success">
<p>Thank you! Your message has been sent.</p>
<button class="btn-reset-form">Send another message</button>
</div>

Wire the reset button to restore the form. This goes outside the if (contactForm) block because .btn-reset-form lives in .form-success, not inside the form element:

const resetBtn = document.querySelector('.btn-reset-form');
if (resetBtn) {
resetBtn.addEventListener('click', () => {
contactForm.reset();
contactForm.style.display = '';
if (successMessage) successMessage.classList.remove('visible');
});
}

contactForm.style.display = '' removes the inline style set by showSuccess(), restoring whatever CSS controls.

updateCharCount must be declared at module scope (outside any if block) so the submit handler can call updateCharCount(0) after a successful submission.

// updateCharCount is declared at module scope in the char-count section above
// const charLimit = 500; (also module scope)
const contactForm = document.querySelector('.contact-form');
const successMessage = document.querySelector('.form-success');
function showError(errorEl, message) {
if (errorEl) {
errorEl.textContent = message;
errorEl.classList.add('visible');
}
}
function clearErrors() {
document.querySelectorAll('.field-error').forEach(el => {
el.textContent = '';
el.classList.remove('visible');
});
}
function showSuccess() {
if (contactForm) {
contactForm.style.display = 'none';
if (successMessage) successMessage.classList.add('visible');
}
}
if (contactForm) {
const nameInput = contactForm.querySelector('#contact-name');
const emailInput = contactForm.querySelector('#contact-email');
const messageInput = contactForm.querySelector('#contact-message');
const nameError = contactForm.querySelector('#name-error');
const emailError = contactForm.querySelector('#email-error');
const messageError = contactForm.querySelector('#message-error');
contactForm.addEventListener('submit', (event) => {
event.preventDefault();
clearErrors();
const name = nameInput.value.trim();
const email = emailInput.value.trim();
const message = messageInput.value.trim();
let isValid = true;
if (!name) { showError(nameError, 'Name is required.'); isValid = false; }
if (!email) { showError(emailError, 'Email is required.'); isValid = false; }
else if (!email.includes('@') || !email.includes('.')) {
showError(emailError, 'Enter a valid email address.'); isValid = false;
}
if (!message) { showError(messageError, 'Message is required.'); isValid = false; }
else if (message.length > 500) {
showError(messageError, 'Message must be 500 characters or fewer.'); isValid = false;
}
if (!isValid) return;
contactForm.reset();
updateCharCount(0);
showSuccess();
});
nameInput.addEventListener('input', () => {
if (nameInput.value.trim()) {
nameError.textContent = '';
nameError.classList.remove('visible');
}
});
emailInput.addEventListener('input', () => {
const email = emailInput.value.trim();
if (email.includes('@') && email.includes('.')) {
emailError.textContent = '';
emailError.classList.remove('visible');
} else if (emailError.classList.contains('visible')) {
showError(emailError, email ? 'Enter a valid email address.' : 'Email is required.');
}
});
messageInput.addEventListener('input', () => {
const message = messageInput.value.trim();
if (message && message.length <= charLimit) {
messageError.textContent = '';
messageError.classList.remove('visible');
} else if (messageError.classList.contains('visible')) {
showError(messageError, message ? 'Message must be 500 characters or fewer.' : 'Message is required.');
}
});
}
const resetBtn = document.querySelector('.btn-reset-form');
if (resetBtn) {
resetBtn.addEventListener('click', () => {
contactForm.reset();
contactForm.style.display = '';
if (successMessage) successMessage.classList.remove('visible');
});
}
  1. Submit empty — all three errors appear
  2. Start typing in the name field — name error disappears
  3. Enter an email without @ — format error shows
  4. Paste a message over 500 characters and submit — length error shows
  5. Fix all fields and submit — form hides, success message shows, character count resets to 0
  6. Click “Send another message” — form reappears, fields are empty
  • Element selections for the form live inside if (contactForm)main.js loads on every page, so a module-scope query returns null on pages without the form.
  • The submit handler reads .value.trim() fresh on each submission — values captured at page load would always be empty strings.
  • clearErrors() uses querySelectorAll('.field-error') — no hardcoded list of error elements to maintain.
  • updateCharCount must be declared at module scope (not inside an if block) so the submit handler can call it after a successful submission.
  • Input handlers check validity before clearing — clearing requires textContent = '' in addition to removing the class, because .field-error has display: block always and errors are hidden by empty content, not by the class. The emailError.classList.contains('visible') guard means error messages update in real time after a submit, but no premature error appears on a fresh field.
  • contactForm.reset() clears fields; updateCharCount(0) syncs the character counter; then showSuccess() swaps the UI.
  • The reset button restores display: '' (removes the inline style) rather than setting display: 'block' — lets CSS control layout normally.