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.
-
HTML Basics
- What is HTML: The Structure Layer of Every Web Page
- HTML Document Structure: DOCTYPE, html, head and body
- Elements, Tags and Attributes: The Vocabulary of HTML
- HTML Comments: Notes That Ship With Your Code
- Block Level and Inline Elements
- Writing and Running Your First HTML Page
- How a Browser Turns Markup Into a Page
- Text and Formatting
- Links and Navigation
- Images and Media
- Lists
- Tables
-
Forms
- Form Structure: form, action and method
- Input Types: Text, Email, Number, Date and the Rest
- Labels: The Most Important Element in a Form
- Checkboxes, Radio Buttons and Grouping
- select, option, optgroup and datalist
- textarea, File Uploads and Hidden Fields
- Buttons: submit, reset and button
- Built In Form Validation
- GET or POST: What Happens When a Form Is Submitted
- Semantic HTML
- HTML5 Features
- Head and Metadata
- HTML with CSS
- HTML with JavaScript
- Accessibility
-
HTML SEO
- How Google Works: Crawling, Indexing and Ranking
- SEO Friendly HTML Structure
- Titles and Descriptions That Earn Clicks
- Headings and Content Structure for Search
- Internal Linking and Anchor Text
- robots.txt and XML Sitemaps
- Canonical URLs and Duplicate Content
- Structured Data and JSON-LD
- Image SEO
- Core Web Vitals and Mobile Friendliness
- DevTools and Debugging
- Editor Productivity
- HTML Best Practices
- HTML Projects
- Advanced Projects
- Practice and Exams
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,autocompleteandinputmodeon every field. - Grouped controls in a
fieldsetwith alegend. - 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
- Validate all three pages.
- Submit each form empty; confirm the summary appears, is announced and links work.
- Complete each form with the keyboard alone.
- Click every label and confirm focus lands on the right field.
- Check a password manager fills the login form.
- Test on a phone: correct keyboards, no zoom on focus, comfortable targets.
- 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
fetchand announce success in the status region. - Add a file upload with the correct
enctypeandaccept.
Practice
- Build all three forms and complete each with the keyboard only.
- Implement the error summary and confirm focus moves to it.
- Add the password match check and test forgetting to clear the custom message.
- Bypass validation in DevTools and describe what the server must do.