Lab: Interactive Form
Build a contact form that validates in JavaScript, shows inline error messages without a page reload, and confirms submission with a success message.
- Wire a submit listener that prevents the default page reload
- Validate multiple fields and show inline error messages linked with aria-describedby
- Show a success state and move keyboard focus to it
- Add a live character counter with the input event
- Audit the result against a keyboard and screen reader checklist
Optional practice lab. This session connects every concept from the module: querying the DOM, reading and writing content, handling events, and processing form data. Work through each part in order, implement it yourself, then compare with the reference.
The starting HTML
Paste this markup into an HTML file and open it in your browser. There is no JavaScript yet — the form submits the old-fashioned way (which reloads the page). Your job is to add the script that takes over.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Contact us</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 600px; margin: 2rem auto; padding: 0 1rem; }
label { display: block; margin-top: 1rem; font-weight: 600; }
input, textarea { display: block; width: 100%; padding: 0.5rem; margin-top: 0.25rem; box-sizing: border-box; }
.error { color: #c00; font-size: 0.875rem; margin-top: 0.25rem; display: none; }
.error.visible { display: block; }
input[aria-invalid="true"], textarea[aria-invalid="true"] { outline: 2px solid #c00; }
#confirm { padding: 1rem; background: #e6f4ea; border-left: 4px solid #2d7a2d; display: none; }
#char-count { font-size: 0.875rem; color: #555; margin-top: 0.25rem; }
</style>
</head>
<body>
<h1>Contact us</h1>
<form id="contact-form" novalidate>
<div>
<label for="name">Name <span aria-hidden="true">*</span></label>
<input type="text" id="name" name="name" autocomplete="name" required>
<p id="name-error" class="error" role="alert"></p>
</div>
<div>
<label for="email">Email address <span aria-hidden="true">*</span></label>
<input type="email" id="email" name="email" autocomplete="email" required>
<p id="email-error" class="error" role="alert"></p>
</div>
<div>
<label for="message">Message <span aria-hidden="true">*</span></label>
<textarea id="message" name="message" rows="5" required></textarea>
<p id="char-count" aria-live="polite">0 / 20 characters minimum</p>
<p id="message-error" class="error" role="alert"></p>
</div>
<button type="submit" style="margin-top:1rem; padding: 0.6rem 1.4rem;">Send message</button>
</form>
<div id="confirm" tabindex="-1" role="status" hidden>
<strong>Message sent!</strong>
<p>Thanks for reaching out. We will reply within two business days.</p>
</div>
<script src="contact.js"></script>
</body>
</html>Create contact.js in the same folder. Each part below adds to it.
Part 1 — Intercept the submit event
Wire the form up so submitting it no longer reloads the page, and log the raw values to confirm everything is wired correctly.
const form = document.querySelector('#contact-form');
const nameInput = document.querySelector('#name');
const emailInput = document.querySelector('#email');
const messageInput = document.querySelector('#message');
form.addEventListener('submit', (event) => {
event.preventDefault();
console.log('name:', nameInput.value);
console.log('email:', emailInput.value);
console.log('message:', messageInput.value);
});Open DevTools and confirm that submitting the form logs all three values without a page reload. Fix any typos before moving on.
Part 2 — Validate and show inline errors
Replace the console.log calls with real validation. Show or hide each error
paragraph and mark the input invalid so screen readers announce the problem.
// Helper — show or clear an error message
function setError(input, errorEl, message) {
if (message) {
errorEl.textContent = message;
errorEl.classList.add('visible');
input.setAttribute('aria-invalid', 'true');
input.setAttribute('aria-describedby', errorEl.id);
} else {
errorEl.textContent = '';
errorEl.classList.remove('visible');
input.removeAttribute('aria-invalid');
input.removeAttribute('aria-describedby');
}
}
const nameError = document.querySelector('#name-error');
const emailError = document.querySelector('#email-error');
const messageError = document.querySelector('#message-error');
// Simple email pattern — enough for client-side UX
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
form.addEventListener('submit', (event) => {
event.preventDefault();
let valid = true;
// Validate name
if (nameInput.value.trim() === '') {
setError(nameInput, nameError, 'Please enter your name.');
valid = false;
} else {
setError(nameInput, nameError, '');
}
// Validate email
if (!emailPattern.test(emailInput.value.trim())) {
setError(emailInput, emailError, 'Please enter a valid email address.');
valid = false;
} else {
setError(emailInput, emailError, '');
}
// Validate message length
if (messageInput.value.trim().length < 20) {
setError(messageInput, messageError, 'Message must be at least 20 characters.');
valid = false;
} else {
setError(messageInput, messageError, '');
}
if (!valid) {
// Move focus to the first invalid field so keyboard users land somewhere useful
form.querySelector('[aria-invalid="true"]').focus();
return;
}
// All valid — proceed (Part 3 handles this)
handleSuccess();
});Test: submit empty, submit with a bad email, submit with a short message. Each should show its error. Fix one at a time and verify the error disappears.
Part 3 — Show the success state
Add the handleSuccess function that hides the form, reveals the confirmation
panel, and moves keyboard focus to it.
const confirm = document.querySelector('#confirm');
function handleSuccess() {
form.hidden = true;
confirm.hidden = false;
confirm.focus(); // screen readers announce the role="status" content
}After calling handleSuccess, the page should show only the confirmation
message. Keyboard users pressing Tab after submit should find focus on the
confirmation div, not on the hidden form.
Part 4 — Live character counter
Add a live counter that updates as the user types in the message textarea. It
uses the input event, which fires on every keystroke.
const charCount = document.querySelector('#char-count');
const MIN_LENGTH = 20;
messageInput.addEventListener('input', () => {
const length = messageInput.value.length;
charCount.textContent = length + ' / ' + MIN_LENGTH + ' characters minimum';
});Because #char-count already has aria-live="polite" in the HTML, screen
readers will announce updates — though they throttle announcements to avoid
reading every keystroke. This is intentional; the number is supplementary
information, not a critical alert.
Self-review checklist
Work through this before comparing to the reference:
Keyboard access
- Tab moves through all fields in a logical order
- Enter in a text field submits the form (do not break this)
- After a failed submission, focus moves to the first invalid field
- After a successful submission, focus moves to the confirmation message
Error messages
- Each error message appears adjacent to its field (not at the top of the form)
- Invalid inputs have
aria-invalid="true"set - Each input points to its error element via
aria-describedby - Errors disappear when the field becomes valid — not just when the form is re-submitted
Character counter
- The counter updates live as the user types
- It accurately reflects the current character count
- It does not interfere with the validation error message for the message field
Success state
- The form is hidden after successful submission
- The confirmation message is visible and readable
- Focus lands on the confirmation div after submission
-
role="status"is present so assistive technology can announce the message
Security
- No user-supplied text is written to
innerHTML— onlytextContentis used for dynamic content (error messages, counter)
Extending the lab (optional)
If you want more practice:
- Add a "Send another message" button inside the confirmation that un-hides the form, hides the confirmation, resets all fields, and moves focus back to the first input.
- Clear the error for a field as soon as the user starts correcting it (use the
inputevent on each field, not just on submit). - Animate the error messages in with a CSS transition — add/remove a class
instead of toggling
display.