Project: Registration, Login and Contact Forms

Three forms that cover almost every input type, validation pattern and accessibility requirement you will meet in real work.

The brief

Build three forms: a registration form, a login form and a contact form. Between them they use most input types, all the validation attributes, and the full accessible error pattern.

Requirements

  • Every control has a visible bound label.
  • Correct type, autocomplete and inputmode on every field.
  • Grouped controls in a fieldset with a legend.
  • Native validation attributes, with errors displayed in the page.
  • Focus moves to the first invalid field on a failed submit.
  • A CSRF token and POST on every form.
  • Errors announced, never signalled by colour alone.

1. Registration

<form action="/register" method="post" novalidate>
  <input type="hidden" name="csrf_token" value="...">

  <h1>Create an account</h1>
  <p>Fields marked <span aria-hidden="true">*</span> are required.</p>

  <div id="error-summary" class="error-summary" role="alert" hidden>
    <h2>There is a problem</h2>
    <ul></ul>
  </div>

  <fieldset>
    <legend>Your details</legend>

    <p>
      <label for="name">Full name <span aria-hidden="true">*</span></label>
      <input type="text" id="name" name="name"
             required autocomplete="name"
             aria-describedby="name-error">
      <span id="name-error" class="error"></span>
    </p>

    <p>
      <label for="email">Email address <span aria-hidden="true">*</span></label>
      <input type="email" id="email" name="email"
             required autocomplete="email"
             aria-describedby="email-hint email-error">
      <small id="email-hint">We will send a confirmation link here.</small>
      <span id="email-error" class="error"></span>
    </p>

    <p>
      <label for="phone">Mobile number</label>
      <input type="tel" id="phone" name="phone"
             inputmode="tel" autocomplete="tel"
             pattern="[0-9]{10}" title="Ten digits, no spaces"
             aria-describedby="phone-hint phone-error">
      <small id="phone-hint">Ten digits, no spaces</small>
      <span id="phone-error" class="error"></span>
    </p>

    <p>
      <label for="dob">Date of birth</label>
      <input type="date" id="dob" name="dob" max="2010-12-31">
    </p>
  </fieldset>

  <fieldset>
    <legend>Password</legend>

    <p>
      <label for="password">Password <span aria-hidden="true">*</span></label>
      <input type="password" id="password" name="password"
             required minlength="12"
             autocomplete="new-password"
             aria-describedby="password-hint password-error">
      <small id="password-hint">At least twelve characters. A passphrase works well.</small>
      <span id="password-error" class="error"></span>
    </p>

    <p>
      <label for="confirm">Confirm password <span aria-hidden="true">*</span></label>
      <input type="password" id="confirm" name="confirm"
             required autocomplete="new-password"
             aria-describedby="confirm-error">
      <span id="confirm-error" class="error"></span>
    </p>
  </fieldset>

  <fieldset>
    <legend>Preferences</legend>

    <p>
      <label>
        <input type="checkbox" name="updates" value="yes">
        Send me occasional course updates
      </label>
    </p>

    <p>
      <label>
        <input type="checkbox" name="terms" value="yes" required
               aria-describedby="terms-error">
        I accept the <a href="/terms">terms of use</a> <span aria-hidden="true">*</span>
      </label>
      <span id="terms-error" class="error"></span>
    </p>
  </fieldset>

  <button type="submit">Create account</button>
</form>

2. Login

<form action="/login" method="post">
  <input type="hidden" name="csrf_token" value="...">

  <h1>Sign in</h1>

  <p>
    <label for="login-user">Email address</label>
    <input type="email" id="login-user" name="user"
           required autocomplete="username" autofocus>
  </p>

  <p>
    <label for="login-pass">Password</label>
    <input type="password" id="login-pass" name="password"
           required autocomplete="current-password">
  </p>

  <p>
    <label>
      <input type="checkbox" name="remember" value="yes">
      Keep me signed in on this device
    </label>
  </p>

  <button type="submit">Sign in</button>

  <p><a href="/forgot-password">Forgotten your password?</a></p>
</form>

Two details here are security relevant. autocomplete="username" and current-password" let a password manager fill correctly, which is what makes long unique passwords practical. And the form is POST over HTTPS - a login over GET puts the password in the URL, in browser history and in every server log along the way.

3. Contact

<form action="/contact" method="post">
  <input type="hidden" name="csrf_token" value="...">

  <h1>Contact us</h1>

  <p>
    <label for="c-name">Your name</label>
    <input type="text" id="c-name" name="name" required autocomplete="name">
  </p>

  <p>
    <label for="c-email">Email address</label>
    <input type="email" id="c-email" name="email" required autocomplete="email">
  </p>

  <fieldset>
    <legend>What is this about?</legend>
    <p><label><input type="radio" name="topic" value="admission" required> Admission</label></p>
    <p><label><input type="radio" name="topic" value="fees"> Fees</label></p>
    <p><label><input type="radio" name="topic" value="other"> Something else</label></p>
  </fieldset>

  <p>
    <label for="c-message">Message</label>
    <textarea id="c-message" name="message" rows="6"
              required maxlength="1000"
              aria-describedby="c-count"></textarea>
    <span id="c-count" aria-live="polite">1000 characters remaining</span>
  </p>

  <button type="submit">Send message</button>
</form>

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

The validation script

const form    = document.querySelector("form[novalidate]");
const summary = document.getElementById("error-summary");

const messages = {
  valueMissing:    "This field is required.",
  typeMismatch:    "Enter a valid email address.",
  patternMismatch: "Use the format shown below the field.",
  tooShort:        (f) => `At least ${f.minLength} characters.`,
};

function messageFor(field) {
  for (const key of Object.keys(messages)) {
    if (field.validity[key]) {
      const m = messages[key];
      return typeof m === "function" ? m(field) : m;
    }
  }
  return field.validationMessage;
}

function checkMatch() {
  const password = form.elements.password;
  const confirm  = form.elements.confirm;
  confirm.setCustomValidity(
    confirm.value === password.value ? "" : "The two passwords do not match."
  );
}

form.addEventListener("input", checkMatch);

form.addEventListener("submit", (event) => {
  const problems = [];

  form.querySelectorAll("input, select, textarea").forEach((field) => {
    const error = document.getElementById(`${field.id}-error`);
    if (!error) return;

    if (field.checkValidity()) {
      error.textContent = "";
      field.removeAttribute("aria-invalid");
    } else {
      const text = messageFor(field);
      error.textContent = text;
      field.setAttribute("aria-invalid", "true");
      problems.push({ id: field.id, text });
    }
  });

  if (problems.length) {
    event.preventDefault();

    summary.querySelector("ul").innerHTML = "";
    problems.forEach(({ id, text }) => {
      const li = document.createElement("li");
      const a  = document.createElement("a");
      a.href = `#${id}`;
      a.textContent = text;
      li.append(a);
      summary.querySelector("ul").append(li);
    });

    summary.hidden = false;
    summary.focus();
  }
});

The error summary is the pattern used by large public service sites, and for good reason: it announces the number of problems immediately, and every entry is a link that moves focus straight to the field.

Styling errors

.error { display: block; color: #b91c1c; font-size: 0.9rem; padding-top: 0.25rem; }
.error:empty { display: none; }

[aria-invalid="true"] {
  border: 2px solid #b91c1c;
  background: #fef2f2;
}

.error-summary {
  border: 3px solid #b91c1c;
  padding: 1rem 1.25rem;
  margin-bottom: 1.5rem;
}
.error-summary:focus-visible { outline: 3px solid #1e3a8a; outline-offset: 2px; }

/* only after interaction, so an untouched form is not red */
input:user-invalid { border-color: #b91c1c; }

label { display: block; font-weight: 600; margin-bottom: 0.25rem; }
input, select, textarea { width: 100%; padding: 0.6rem; font-size: 16px; }
fieldset { border: 1px solid #cbd5e1; padding: 1rem 1.25rem; margin-bottom: 1.5rem; }
legend { font-weight: 600; padding-inline: 0.5rem; }

The font-size: 16px on form fields is deliberate: below that, iOS zooms in on focus and does not zoom back out. Fixing it here is the correct alternative to disabling zoom in the viewport tag.

Checking your work

  1. Validate all three pages.
  2. Submit each form empty; confirm the summary appears, is announced and links work.
  3. Complete each form with the keyboard alone.
  4. Click every label and confirm focus lands on the right field.
  5. Check a password manager fills the login form.
  6. Test on a phone: correct keyboards, no zoom on focus, comfortable targets.
  7. Confirm the server rejects data that bypasses the client validation.

Extensions

  • Add a password strength indicator using meter.
  • Add a show password toggle as a real button with aria-pressed.
  • Split registration into steps with a progress indicator.
  • Submit with fetch and announce success in the status region.
  • Add a file upload with the correct enctype and accept.

Practice

  1. Build all three forms and complete each with the keyboard only.
  2. Implement the error summary and confirm focus moves to it.
  3. Add the password match check and test forgetting to clear the custom message.
  4. Bypass validation in DevTools and describe what the server must do.

Useful resources

Hand picked references for this topic
Written by Lorens Mishra

Software Engineer Notes Management System Administrator

Continue reading

All HTML notes →
HTML

Project: A Resume Page

A document with real structure: dated entries, an experience timeline, skills grouped by category, and a print stylesheet that produces a usable PDF.

Read more
HTML

Project: A Portfolio Page

A grid of project cards with a filter, responsive images and a case study page. The project where card markup, image performance and clickable regions...

Read more

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.