Code of the Day
JavaScript in the BrowserJavaScript in the Browser

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.

Lab · optionalWeb FoundationsDialect appendices25 min
Recommended first
By the end of this lesson you will be able to:
  • 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 — only textContent is 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 input event on each field, not just on submit).
  • Animate the error messages in with a CSS transition — add/remove a class instead of toggling display.
Finished reading? Mark it complete to track your progress.

On this page