Chapter 2
HTML Forms and Validation
Labels, input types, fieldsets, native validation and accessible error messages.
2. HTML Forms and Validation
Forms are the browser's built-in interface for collecting and submitting information. A well-built form does more than place text boxes on a page: it gives every control a useful name, communicates constraints, works with a keyboard and assistive technology, and submits predictable data to a server. Native HTML provides a strong foundation before JavaScript is involved.
This chapter builds that foundation, then adds validation and accessible error handling. Client-side checks improve the interaction, but they never replace validation on the server.
Overview & Objectives
By the end of this chapter, you will be able to:
- Explain how a form submits named controls using an action and method.
- Associate visible labels with controls and select appropriate input types.
- Group related controls with
fieldsetandlegend. - Use native constraint validation and the Constraint Validation API.
- Present field errors so they are perceivable and actionable.
- Build accessible login and multi-step registration forms.
- Diagnose common form bugs with browser developer tools.
Form Anatomy and Submission
A <form> is a submission boundary. Controls associated with it contribute name/value pairs when the user submits it. The action identifies where the browser sends the data; method selects how it is sent.
<form action="/search" method="get">
<label for="site-query">Search articles</label>
<input id="site-query" name="q" type="search">
<button type="submit">Search</button>
</form>When submitted, this GET form navigates to a URL similar to /search?q=semantic+html. GET is appropriate for a read-only request whose result can be bookmarked or shared. Do not submit passwords or sensitive personal information in a GET query: URLs may be recorded in browser history, server logs, analytics, and referrer data.
Use POST when submitting data that creates a resource, changes state, or should not be encoded in the URL:
<form action="/account/profile" method="post">
<label for="display-name">Display name</label>
<input id="display-name" name="displayName" autocomplete="name" required>
<button type="submit">Save profile</button>
</form>The name is the key sent to the server. An id connects a label and other references to a particular control; it is not a replacement for name. A control without a name is normally omitted from the submitted data. Disabled controls are also omitted. Read-only controls remain focusable and are submitted.
Important submission rules:
- A
<button>inside a form defaults totype="submit". Declaretype="button"for buttons that only reveal a password, move between steps, or perform another client-side action. - A submit button's
nameandvaluecan identify which submit action was selected. This is useful when a form has distinct actions such as “Save draft” and “Publish.” - Pressing Enter in an appropriate field may submit the form. Keep a real submit button so the action is visible and operable.
- Browser validation happens before a normal submission. A failed constraint prevents submission and focuses the invalid control.
- Form data is not automatically saved to a backend by having HTML markup. The destination must exist and handle the request.
For client-side code, new FormData(form) reads successful controls using their names. To inspect the exact request in developer tools, open the Network panel, submit the form, and examine its URL, method, payload, and response.
Labels and Input Types
Every control needs a clear accessible name. The most robust pattern is an explicit <label> whose for value matches the control's id:
<label for="email">Email address</label>
<input id="email" name="email" type="email" autocomplete="email" required>Clicking the label focuses the input, and assistive technology announces the label with the control. A label can also wrap the control (implicit association):
<label>
Email address
<input name="email" type="email" autocomplete="email" required>
</label>Explicit labels are often easier to style and maintain, especially when the form contains multiple controls. Do not use placeholder text as the only label. Placeholders disappear as the user types, may have weak contrast, and are not consistently presented as names by assistive technology.
Choose an input type that matches the data:
| Type | Useful for | Important detail |
|---|---|---|
email |
Email addresses | Provides format checking and may show an email-friendly keyboard on mobile. It does not prove the address exists. |
tel |
Telephone numbers | Does not impose a universal phone format. Use it to request a telephone keypad, then validate according to the application's requirements. |
url |
Web addresses | Checks URL-like syntax, not whether a destination is reachable. |
number |
Quantities with arithmetic meaning | Offers numeric controls and min, max, and step. It is usually a poor fit for postal codes, account numbers, or phone numbers. |
date |
Calendar dates | The picker and displayed format vary by browser and locale. The submitted value is normalized. |
file |
File selection | accept can guide the picker, but the server must still inspect and validate uploaded files. |
password |
Secret text entry | Use the correct autocomplete token and never log the value. |
search |
Search query | Adds search semantics and may provide browser-specific clearing UI. |
An input type improves data entry; it does not guarantee correct or safe data. For example, type="email" accepts a syntactically plausible value, not a verified mailbox. The server must validate according to its own rules and handle malformed or hostile input safely.
Related attributes
autocompletetells the browser what kind of information a field accepts, such asname,email,username,current-password, ornew-password. Correct values help password managers and users.inputmodehints which virtual keyboard to show without changing the input's validation semantics. For example,inputmode="numeric"can be useful for a code whose leading zeros matter.requiredmakes a control mandatory for native validation.minlengthandmaxlengthconstrain text length;minandmaxconstrain numeric or date values.patternrequires the entire text value to match a regular expression. It is not a substring search.
Use autocomplete="off" sparingly. Browsers and password managers may ignore it for login fields because credential autofill is beneficial to users.
Grouping Controls with fieldset and legend
Use <fieldset> to group controls that belong together and <legend> to name the group. This is especially useful for radio buttons and checkboxes:
<fieldset>
<legend>Choose a contact method</legend>
<label>
<input type="radio" name="contactMethod" value="email" required>
Email
</label>
<label>
<input type="radio" name="contactMethod" value="phone">
Phone
</label>
</fieldset>Radio buttons with the same name form one choice group; only one value is submitted. The required constraint on one radio in that group requires a choice from the group. Checkboxes are independent; unchecked checkboxes are not submitted. Use a fieldset only when a meaningful group exists, not as a generic layout wrapper.
Native Constraint Validation
HTML constraints let the browser guide users before the form is sent:
<label for="handle">Username</label>
<input
id="handle"
name="handle"
required
minlength="3"
maxlength="24"
pattern="[A-Za-z][A-Za-z0-9_-]*"
title="Start with a letter; use letters, numbers, hyphens, or underscores."
aria-describedby="handle-hint"
>
<p id="handle-hint">Use 3–24 characters and start with a letter.</p>Here, required rejects an empty value, minlength and maxlength check text length, and pattern restricts allowed characters. A title can provide a pattern hint in browser validation messages, but it is not a substitute for visible instructions. Keep the same rule understandable in visible text.
The browser exposes each control's state through :valid and :invalid. Avoid showing a red error state immediately on page load just because a required field is empty. A form may instead indicate errors after submission or after the user has interacted with a field. A neutral initial style is often clearer.
The Constraint Validation API gives scripts access to these checks:
const form = document.querySelector("form");
const email = form.elements.namedItem("email");
if (!form.checkValidity()) {
form.reportValidity();
}
email.setCustomValidity("Enter an email address from your organisation.");
// Clear a custom message when the value changes; otherwise it stays invalid.
email.addEventListener("input", () => email.setCustomValidity(""));checkValidity()returns whether the form or control meets its constraints and firesinvalidevents for failures.reportValidity()does the same and asks the browser to show its validation UI.setCustomValidity(message)adds an application-specific constraint. Pass the empty string to clear it.- A non-empty custom message keeps the field invalid even if its built-in constraints pass. Always clear it when the user can correct the issue.
Do not use form.submit() to submit a form after custom validation: that method bypasses constraint checks and the submit event. Use the user's submit action or form.requestSubmit() when code must trigger normal form submission.
Accessible Error Messaging
Native validation is a useful first layer, but custom interfaces often need persistent, specific messages. Each error should identify the affected field, describe the problem in plain language, and tell the user how to fix it.
Connect a field to its hint and error with aria-describedby. Set aria-invalid="true" while it has an error:
<label for="work-email">Work email</label>
<input
id="work-email"
name="email"
type="email"
required
aria-invalid="true"
aria-describedby="email-hint email-error"
>
<p id="email-hint">Use the address associated with your account.</p>
<p id="email-error">Enter a valid email address, such as name@example.com.</p>aria-describedby is broadly supported and can reference both concise instructions and an error. aria-errormessage can additionally associate an invalid field with its error in current assistive technology, but support varies; do not rely on it as the only connection. Keep error elements in the document only when relevant, or hide them until there is an error. Do not set aria-invalid="true" on untouched empty required controls on first render.
For a failed multi-field submission, provide an error summary near the form heading. The summary should explain that submission failed and link to each invalid control. Move focus to the summary after rendering it, or focus the first invalid field; do not steal focus on every keystroke. For asynchronous server errors, use a concise live region such as role="status" for neutral updates and role="alert" for urgent errors. Avoid announcing the same error both in a live region and repeatedly through another mechanism.
Associate server-side errors with the corresponding field and preserve safe user-entered values when re-rendering. Never echo passwords, authentication tokens, or other secrets into HTML or logs.
Complete Example: Accessible Login
This example uses a normal POST form. /session must be implemented by the application server; the client markup does not authenticate a user on its own.
<form action="/session" method="post">
<h1>Sign in</h1>
<div>
<label for="login-email">Email address</label>
<input
id="login-email"
name="email"
type="email"
autocomplete="username"
required
aria-describedby="login-email-hint"
>
<p id="login-email-hint">Use the email address on your account.</p>
</div>
<div>
<label for="login-password">Password</label>
<input
id="login-password"
name="password"
type="password"
autocomplete="current-password"
required
>
</div>
<button type="submit">Sign in</button>
<p><a href="/password-reset">Forgot your password?</a></p>
</form>Why these details matter:
- Each control has an explicit, visible label. The hint is associated without making the input's name ambiguous.
type="email"andrequiredgive immediate, built-in feedback, but the server must still verify credentials and return a safe response.autocomplete="username"is the appropriate token for the account identifier even when that identifier is an email address.current-passworddistinguishes an existing password from a password being created.- A password reset is navigation, so it is a link rather than a button.
- HTTPS is essential in production. The server should use secure session cookies and appropriate CSRF defenses when required by its authentication design. Do not store a password in browser storage or treat client-side validation as authentication.
If authentication fails, use a generic response such as “Email or password was not recognised” when revealing whether an account exists would create an account-enumeration risk. Preserve the email if appropriate, but never refill the password.
Complete Example: Multi-Step Registration
A multi-step form is still one form. Each step is grouped in a fieldset, and the current step is identified with a heading. Hidden controls do not participate in constraint validation, so required fields on later steps do not block the current step. This small script validates the visible step before advancing and leaves final submission to the server.
<form action="/accounts" method="post" id="registration">
<h1>Create your account</h1>
<p id="step-status" role="status" aria-live="polite">Step 1 of 2: Your details</p>
<fieldset id="step-details" aria-describedby="step-status">
<legend>Your details</legend>
<label for="full-name">Full name</label>
<input id="full-name" name="name" autocomplete="name" required>
<label for="new-email">Email address</label>
<input id="new-email" name="email" type="email" autocomplete="email" required>
<button type="button" id="continue">Continue</button>
</fieldset>
<fieldset id="step-security" aria-describedby="step-status" hidden>
<legend>Choose a password</legend>
<label for="new-password">Password</label>
<input
id="new-password"
name="password"
type="password"
autocomplete="new-password"
minlength="12"
required
aria-describedby="password-hint"
>
<p id="password-hint">Use at least 12 characters.</p>
<label>
<input name="terms" type="checkbox" value="accepted" required>
I agree to the terms of service.
</label>
<button type="button" id="back">Back</button>
<button type="submit">Create account</button>
</fieldset>
</form>
<script>
const form = document.querySelector("#registration");
const details = document.querySelector("#step-details");
const security = document.querySelector("#step-security");
const status = document.querySelector("#step-status");
document.querySelector("#continue").addEventListener("click", () => {
const fields = [...details.querySelectorAll("input")];
const firstInvalid = fields.find((field) => !field.checkValidity());
if (firstInvalid) {
firstInvalid.reportValidity();
return;
}
details.hidden = true;
security.hidden = false;
status.textContent = "Step 2 of 2: Choose a password";
security.querySelector("input").focus();
});
document.querySelector("#back").addEventListener("click", () => {
security.hidden = true;
details.hidden = false;
status.textContent = "Step 1 of 2: Your details";
details.querySelector("input").focus();
});
form.addEventListener("submit", (event) => {
if (!form.checkValidity()) {
event.preventDefault();
const firstInvalid = form.querySelector(":invalid");
const hiddenStep = firstInvalid.closest("fieldset");
if (hiddenStep?.hidden) {
details.hidden = hiddenStep !== details;
security.hidden = hiddenStep !== security;
status.textContent =
hiddenStep === details ? "Step 1 of 2: Your details" : "Step 2 of 2: Choose a password";
}
firstInvalid.reportValidity();
firstInvalid.focus();
}
});
</script>The step buttons explicitly use type="button" so they do not submit the form. The Continue handler checks only the currently visible fieldset. The final submit handler validates the entire form; if a constraint fails on a hidden step, it reveals that step before focusing the invalid control. With JavaScript disabled, this particular step-navigation interface cannot advance, so a production application should either progressively enhance a single-page form or provide a server-rendered, usable alternative.
In a production registration flow, explain password rules before the user starts typing, avoid arbitrary composition rules, and validate on the server. If the steps are separate server requests, protect state appropriately and avoid placing secrets in URLs.
Common Traps, Anti-patterns & Debugging
Missing name or wrong button type
An input can look correct but submit no value if it has no name. A Continue button can unexpectedly submit if its type is omitted. Inspect the request payload in the Network panel and explicitly assign names and button types.
Placeholder used as the only label
When a placeholder disappears, users lose the visible prompt; assistive technology may not expose it consistently as the control name. Add a real <label> and keep placeholders for optional examples only.
Validation that is too strict or unclear
Overly narrow pattern expressions reject valid names, addresses, or phone formats. Test constraints with realistic valid and invalid examples. If a rule is needed, describe it before the user submits and make the correction specific.
Custom errors that never clear
setCustomValidity("...") keeps a control invalid until cleared. Clear the message when the user edits the value, and do not leave stale aria-invalid or error text after the issue is fixed.
Hidden invalid fields
A hidden required control can create an unusable final submission if the code does not reveal the step containing it. Before focusing an invalid field, make its step visible, update the step announcement, then focus it.
Trusting browser checks
Users can bypass JavaScript and alter requests. Validate all data on the server, enforce authorization there, and treat uploaded files as untrusted. The accept attribute is a picker hint, not an upload security check.
A practical debugging sequence
- Inspect the element in DevTools. Confirm the expected
id,name,type,required, and form association. - Run
new FormData(document.querySelector("form"))in the console and inspect its entries. Missing names, unchecked checkboxes, disabled controls, and unselected radio groups explain many absent values. - Check
input.validityandinput.validationMessage. Properties such asvalueMissing,typeMismatch,tooShort,patternMismatch, andcustomErroridentify which constraint failed. - Submit with only the keyboard. Confirm Enter submits where appropriate, buttons work with Space/Enter, focus remains visible, and validation moves focus to a usable field.
- In the accessibility tree, verify each control has the expected name, group context, required state, and error description.
- Inspect the server response as well as the browser state. A form that passes client validation can still fail server rules or authentication.
Accessibility & Best Practices
- Use native form controls and submit buttons before building custom widgets.
- Give every input a visible label; use
fieldsetandlegendfor related choices. - Write concise instructions before users need them. Mark required fields clearly and do not communicate errors by color alone.
- Preserve logical DOM and keyboard order. Do not move focus unexpectedly as a user types.
- On failed submission, summarize errors and provide links to fields or focus the first invalid field after revealing its step.
- Pair each inline error with its field using
aria-describedby; set and cleararia-invalidas the actual state changes. - Use correct
autocompletetokens to support browser autofill and password managers. - Use POST for state-changing or sensitive submissions, transport production data over HTTPS, and avoid logging credentials.
- Validate and normalize input on the server. Client-side validation is convenience, not a security boundary.
- Test with a keyboard, browser validation UI, zoom, and at least one screen reader when the form is important to the service.
Practice Exercises
Exercise 1 — Repair an account form
The following form has missing labels, weak input semantics, and a button that does not identify its purpose. Rewrite it for an account sign-in page. Include a usable password-reset link and appropriate autocomplete values.
<form action="/login">
<input placeholder="Email">
<input placeholder="Password">
<button>Go</button>
</form>Exercise 2 — Build a validated contact preference group
Create a form fragment that asks whether the user prefers email or telephone contact. It must:
- Use an accessible group name.
- Require exactly one contact preference.
- Collect an email address only when email is selected and a telephone number only when phone is selected.
- Give each text field a visible label and suitable input type.
Describe which parts require JavaScript or server-side handling if irrelevant fields should not be submitted.
Solutions
Solution 1
<form action="/session" method="post">
<h1>Sign in</h1>
<label for="account-email">Email address</label>
<input
id="account-email"
name="email"
type="email"
autocomplete="username"
required
>
<label for="account-password">Password</label>
<input
id="account-password"
name="password"
type="password"
autocomplete="current-password"
required
>
<button type="submit">Sign in</button>
<a href="/password-reset">Forgot your password?</a>
</form>The explicit labels remain visible after typing and associate with their controls. name supplies the submitted keys, type="email" offers appropriate email validation, and required prevents empty submission in supporting browsers. autocomplete="username" and current-password enable the browser's credential tools. POST avoids putting the credentials into the URL; HTTPS and server-side authentication are still required. The reset action is a link because it navigates to another page.
Solution 2
<form action="/contact-preference" method="post" id="contact-preference">
<fieldset>
<legend>How should we contact you?</legend>
<label>
<input type="radio" name="contactMethod" value="email" required>
Email
</label>
<label>
<input type="radio" name="contactMethod" value="phone">
Telephone
</label>
</fieldset>
<div id="email-option">
<label for="contact-email">Email address</label>
<input id="contact-email" name="email" type="email" autocomplete="email" required>
</div>
<div id="phone-option" hidden>
<label for="contact-phone">Telephone number</label>
<input id="contact-phone" name="phone" type="tel" autocomplete="tel" required disabled>
</div>
<button type="submit">Save preference</button>
</form>
<script>
const preferenceForm = document.querySelector("#contact-preference");
const emailRadio = preferenceForm.querySelector('[value="email"]');
const phoneRadio = preferenceForm.querySelector('[value="phone"]');
const emailInput = preferenceForm.elements.namedItem("email");
const phoneInput = preferenceForm.elements.namedItem("phone");
const emailOption = document.querySelector("#email-option");
const phoneOption = document.querySelector("#phone-option");
function updateContactFields() {
const useEmail = emailRadio.checked;
emailOption.hidden = !useEmail;
emailInput.disabled = !useEmail;
phoneOption.hidden = !phoneRadio.checked;
phoneInput.disabled = !phoneRadio.checked;
}
preferenceForm
.querySelectorAll('input[name="contactMethod"]')
.forEach((radio) => radio.addEventListener("change", updateContactFields));
updateContactFields();
</script>The fieldset gives the radio choices shared context and its legend names the group. The shared name makes the radios mutually exclusive, and required requires one selection. email and tel are appropriate types. The script reveals and enables only the selected contact field; the other control is disabled, so it is neither validated nor included in submitted form data. The server must still verify that the selected preference has a matching, valid contact value and reject inconsistent requests. Without JavaScript, this conditional interface cannot work; a no-JavaScript alternative could show both optional fields and let the server validate the selected preference.
Do not rely on client-side conditional validation for security or data integrity.