Accessible HTML Forms: Semantics and Validation
Introduction
This lesson opens chapter 4, aligned with course 4 of the Meta Front-End Developer pathway ("HTML and CSS in Depth"). Build on semantic HTML and accessible page structure from chapter 1. Now you will build accessible HTML forms that let visitors book, search, and subscribe. Booking a workshop, signing up for a newsletter, searching a catalog, and logging in are all forms. A form that is hard to understand, hard to use with a keyboard, or silent about errors costs a business real customers and excludes people who use assistive technology.
You will learn to build forms with the right elements and attributes, label every control, validate input in the browser with built-in HTML features, communicate errors accessibly, and use semantic HTML so that browsers, search engines, and screen readers understand your page. The practice scenario is original: the Harbor Workshops booking form, which collects a name, an email address, a date, the number of seats, and a dietary note.
Learning objectives
- Build a form with
form,label,input,select,textarea,fieldset,legend, andbutton. - Choose the correct
inputtype and attributes (required,min,max,pattern,autocomplete,inputmode). - Connect every control to a visible label and explain why placeholders are not labels.
- Use native constraint validation and the Constraint Validation API, and explain why the server must validate too.
- Present validation errors accessibly with
aria-describedby,aria-invalid, and live regions. - Choose between
GETandPOST, and explain whataction,method, andnamedo. - Apply landmark and text-level semantic elements and a correct heading structure.
- Test a form with the keyboard and describe what a screen reader announces.
1. Why semantics matter
A semantic element describes the meaning of its content, not just its look. An enabled <button> provides focus, Enter/Space activation, and button semantics. A submit button associated with a form submits it; type="button" does not. A <div onclick="..."> styled to look like a button does none of that unless you rebuild it by hand, and you will almost surely miss something.
Benefits of semantic HTML:
- Accessibility: assistive technology builds its view of the page from the accessibility tree, which comes from the HTML semantics.
- Keyboard support for free: links, buttons, and form controls are focusable by default.
- SEO and tooling: headings, lists, and landmarks help search engines and reader modes.
- Maintainability:
<nav>is clearer than<div class="nav">.
The first rule of ARIA (the accessibility attributes) is: use a native HTML element if one exists. ARIA can supply names, descriptions, and states that complement HTML; it does not add keyboard behavior or validation.
Page structure
| Element | Role |
|---|---|
<header> |
Introductory content of the page or a section (logo, title) |
<nav> |
A block of major navigation links |
<main> |
The main content; only one non-hidden main per document |
<section> |
A thematic group, normally with a heading |
<article> |
Self-contained content that makes sense alone (a workshop card, a post) |
<aside> |
Related but secondary content |
<footer> |
Closing content (contact, legal links) |
Use headings (<h1> to <h6>) to form an outline: one <h1> describing the page, then <h2> sections, <h3> subsections. Do not choose a heading level for its size. Choose it for its place in the outline and style it with CSS. Skipping from <h2> to <h5> makes the hierarchy harder to navigate for people who jump between headings.
Text-level elements add meaning too: <strong> (importance), <em> (stress emphasis), <time datetime="2026-11-05"> (a machine-readable date), <address> (contact information), <abbr title="...">, <ul>/<ol> for lists and <table> for tabular data only.
2. Building a form
The skeleton
<form action="/api/bookings" method="post">
<label for="name">Full name</label>
<input id="name" name="name" type="text" autocomplete="name" required>
<button type="submit">Book seats</button>
</form>actionis the URL that receives the data;methodis the HTTP method.nameis the key the data is sent under (name=Amira). A control without anameis not submitted, a frequent surprise.idlinks the control to its<label for="...">and to CSS or JavaScript.- A
<button>inside a form defaults totype="submit". Usetype="button"for buttons that should not submit.
GET and POST
| Method | Data location | Use for | Notes |
|---|---|---|---|
GET |
In the URL query string (?q=pottery) |
Searches and filters; safe, repeatable requests | Can be bookmarked and shared; never use for passwords |
POST |
In the request body | Creating or changing data (bookings, login) | Not in the URL; resubmission can repeat the operation |
POST is not encryption: use HTTPS, and do not put secrets in URLs. The example /api/... endpoints need a server implementation; passing browser checks does not confirm a booking.
Review DOM events and JavaScript form handling before intercepting submission with fetch; the HTML foundations stay the same.
Labels
Every control needs a programmatically associated label. Two ways:
<!-- explicit: for/id -->
<label for="email">Email address</label>
<input id="email" name="email" type="email">
<!-- implicit: wrapping -->
<label>Email address <input name="email" type="email"></label>A label gives screen reader users the control's name and also enlarges the click target: clicking the label focuses the field. A placeholder is not a label: it disappears when the user types, often has weak contrast, and is not reliably announced. Use it only for an example format ("name@example.com") in addition to a real label.
Grouping
<fieldset>
<legend>Preferred session</legend>
<label><input type="radio" name="session" value="morning" required> Morning</label>
<label><input id="session-evening" type="radio" name="session" value="evening"> Evening</label>
</fieldset>fieldset and legend group related controls (radio buttons, checkboxes) and give the group a name that assistive technology can expose with the options; exact announcements depend on the browser and screen reader. Radio buttons that share the same name form one group where only one can be selected.
Input types
The type attribute selects the control and often brings a better mobile keyboard and built-in checks.
| Type | Use | Free benefit |
|---|---|---|
text |
Short text | None |
email |
Email address | Format check, email keyboard |
tel |
Phone number | Phone keypad (no format check) |
number |
Quantities | Number UI varies by device; min, max, step |
date |
Date | Date picker, min, max |
password |
Secrets | Masked text |
url |
Web address | URL check |
search |
Search term | Clear button on some browsers |
checkbox / radio |
Choices | Native toggling |
hidden |
Value not shown | Submitted but invisible, not secure |
Other controls: <select> with <option> for a closed list, <textarea> for multi-line text, and <datalist> for suggestions.
Helpful attributes:
autocomplete="name","email","tel","street-address": lets the browser fill the field, which helps everyone and is an accessibility requirement for identifying common input purposes.inputmode="numeric": requests a numeric keypad for a text field that is not really a number, such as a postal code.disabled: the control cannot be used and is not submitted.readonly: supported text-like inputs andtextareacannot be edited but remain focusable and can be submitted. It does not apply to selects or checkboxes. Disabled and read-only controls are excluded from constraint validation.autofocus: use sparingly; it can disorient screen reader users.
3. Validation
Native HTML validation
Browsers validate according to attributes before the form is submitted.
<label for="seats">Seats (1 to 6)</label>
<input id="seats" name="seats" type="number" min="1" max="6" step="1" required>
<label for="date">Workshop date</label>
<input id="date" name="date" type="date" min="2026-11-01" required>
<label for="code">Voucher code</label>
<input id="code" name="code" type="text" pattern="[A-Z]{3}-[0-9]{4}"
aria-describedby="code-hint">
<p id="code-hint">Three capital letters, a dash, four digits, for example HRB-2026.</p>| Attribute | Checks |
|---|---|
required |
A value is present |
minlength / maxlength |
Text length |
min / max / step |
Numeric or date range |
pattern |
Regular expression, matched against the whole value |
type="email" / "url" |
Basic format |
The date minimum above is a fixed exercise value, not current workshop availability. pattern applies to supported text-like inputs, matches the entire nonempty value, and uses the Unicode-aware v flag in the current HTML standard. An empty optional field passes; add required when needed. Give format instructions in visible text rather than relying on title.
Pseudo-classes let CSS react: :valid, :invalid, :required, :user-invalid (the browser decides when interaction warrants showing an error, avoiding immediate error styling on initial load). Prefer :user-invalid over :invalid for styling error states where it is supported by your target browsers.
novalidate skips automatic interactive validation during submission. It does not disable checkValidity() or reportValidity(). Use it only with complete checks and accessible messages. Without it, an invalid form normally never dispatches submit; do not expect a submit handler to catch those native failures.
The Constraint Validation API
JavaScript can read and customize validation:
const seats = document.querySelector("#seats");
// Use this standalone example with the native-validation form above.
seats.addEventListener("input", () => {
if (seats.validity.rangeOverflow) {
seats.setCustomValidity("This workshop has at most 6 seats per booking.");
} else {
seats.setCustomValidity(""); // clear this custom error; other constraints still apply
}
});
// Native submission checks the field and displays the custom message.
// seats.checkValidity() returns a boolean; seats.reportValidity() also shows UI.Remember to reset a custom message with setCustomValidity(""), otherwise the field stays invalid forever.
Client validation is not security
Validation in the browser is a usability feature. Anyone can bypass it by editing the HTML, disabling JavaScript, or sending a request with a tool. The server must validate everything again and must never trust the data. Treat browser validation as faster feedback, and server validation as the real check. The same applies to maxlength, hidden fields, and prices computed in the browser.
Accessible error messages
A red border alone fails people who cannot see color. A good error:
- is described in text, next to the field, that says what is wrong and how to fix it ("Enter a date on or after 1 November 2026", not "Invalid");
- is programmatically linked to the field so a screen reader reads it;
- does not depend on color alone (add an icon or the word "Error");
- is announced when it appears, and focus goes to the first problem field or an error summary.
<label for="email">Email address</label>
<input id="email" name="email" type="email" required
aria-invalid="true" aria-describedby="email-hint email-error">
<p id="email-hint">We send your confirmation here.</p>
<p id="email-error" class="error">Error: enter an email address such as name@example.com.</p>The markup above shows the state after a failed check. Remove the error state when corrected. aria-describedby references one or more element ids whose text is read after the control's name. aria-invalid="true" tells assistive technology the value is invalid; set it only after validation, not before. For messages that appear dynamically, a container with role="alert" or aria-live="polite" can announce changes without moving focus. Keep the live region in the DOM before updating it and test announcements; combining alerts with moved focus can cause duplicate speech.
Messages in the form-level summary should link to each problem field (<a href="#email">), so keyboard users can jump to the field.
4. Worked example: the Harbor Workshops booking form
Brief. Build an accessible booking form with a name, email, date, number of seats (1 to 6), session choice, and optional dietary notes. Validate every eligible control in the browser and link each error to its field. This example uses native checks with custom presentation; it does not implement the server.
Step 1: Structure. Use landmarks, one h1, and a form with labelled controls.
<main>
<h1>Book a workshop</h1>
<p>Fields marked <abbr title="required">*</abbr> are required.</p>
<form id="booking-form" action="/api/bookings" method="post">
<div>
<label for="name">Full name *</label>
<input id="name" name="name" type="text" autocomplete="name" required>
</div>
<div>
<label for="email">Email address *</label>
<input id="email" name="email" type="email" autocomplete="email" required
aria-describedby="email-hint">
<p id="email-hint">We send your confirmation here.</p>
</div>
<div>
<label for="date">Date *</label>
<input id="date" name="date" type="date" min="2026-11-01" required>
</div>
<div>
<label for="seats">Seats (1 to 6) *</label>
<input id="seats" name="seats" type="number" min="1" max="6" step="1" required>
</div>
<fieldset>
<legend>Session</legend>
<label><input id="session-morning" type="radio" name="session" value="morning" checked> Morning</label>
<label><input id="session-evening" type="radio" name="session" value="evening"> Evening</label>
</fieldset>
<div>
<label for="notes">Dietary notes (optional)</label>
<textarea id="notes" name="notes" rows="3" maxlength="300"></textarea>
</div>
<button type="submit">Book seats</button>
</form>
</main>Step 2: Validate every field and clear stale errors. Load this code as an external script with defer, or place it after the form markup. Native validation remains the fallback if the script is unavailable.
const form = document.querySelector("#booking-form");
const fields = [...form.querySelectorAll("input, select, textarea")]
.filter(field => field.willValidate);
const descriptions = new Map(fields.map(field => [
field, field.getAttribute("aria-describedby") || ""
]));
function clearError(field) {
document.getElementById(`${field.id}-error`)?.remove();
field.removeAttribute("aria-invalid");
const original = descriptions.get(field);
if (original) field.setAttribute("aria-describedby", original);
else field.removeAttribute("aria-describedby");
}
function showError(field) {
const error = document.createElement("p");
error.id = `${field.id}-error`;
error.className = "error";
error.textContent = `Error: ${field.validationMessage}`;
field.after(error);
field.setAttribute("aria-invalid", "true");
field.setAttribute("aria-describedby",
[descriptions.get(field), error.id].filter(Boolean).join(" "));
}
form.addEventListener("submit", event => {
fields.forEach(clearError);
if (!form.checkValidity()) {
event.preventDefault();
const invalid = fields.filter(field => !field.validity.valid);
invalid.forEach(showError);
invalid[0]?.focus();
}
// If valid, allow the ordinary POST. Only the server can confirm it.
});
for (const field of fields) {
const clearIfFixed = () => {
if (field.validity.valid) clearError(field);
};
field.addEventListener("input", clearIfFixed);
field.addEventListener("change", clearIfFixed);
}
// Enable custom presentation only after handlers are ready.
form.noValidate = true;Every eligible control is checked, including the required name/date and the seats range and step. Each control in this example has a unique id; existing help text stays linked. The session has a default selected radio. A form with initially empty required radio groups should present one error per group rather than repeat it beside every option. If invalid input remains, resubmission refreshes its message; correcting a field clears its stale error. Browser-provided validationMessage wording varies by language and browser.
The client checks do not verify inventory or email ownership. Length constraints also have limitations for values assigned by script; the server must independently enforce all rules. Test announcements with a screen reader and preserve visible error text even if speech is unavailable.
Step 3: Keyboard test. Press Tab through the page. Focus should move in the visual order (name, email, date, seats, session, notes, button), a visible focus outline must appear on each control, arrow keys choose a radio option, and Enter in a text field submits the form.
Step 4: Why not the alternatives.
- Using
placeholder="Full name"instead of a<label>would leave screen reader users and users who have typed with no field name. - Using
<div class="button">would require keyboard handling, a role, and focus styles that<button>already has. - Using
type="text"for seats would lose the numeric keypad and themin/maxchecks. - Relying on
maxlength="300"and the browser checks alone would let a crafted request send 10 MB of notes; the server must re-validate.
5. Comparison tables
Native validation UI versus custom presentation
| Native browser UI | Custom messages using native checks | |
|---|---|---|
| Setup | Attributes only | Code needed |
| Messages | Browser wording, varies by language and browser | Fully controllable |
| Rules | Built-in constraints | Built-in constraints plus custom rules; server checks are separate |
| Accessibility | Browser UI varies; labels still required | Link messages and manage focus |
| Best for | Most basic forms | Rules and messages beyond native ones |
disabled versus readonly on supported text controls
disabled |
readonly |
|
|---|---|---|
| Can the user edit it? | No | No |
| Focusable? | No | Yes |
| Submitted with the form? | No | Yes |
| Typical use | Option not currently available | Value shown for confirmation |
Placeholder versus label versus hint
| Element | Purpose | Persists while typing? |
|---|---|---|
<label> |
Names the control | Yes |
Hint text (aria-describedby) |
Format and help | Yes |
placeholder |
Short example only | No |
6. Pitfalls and study tips
- Pitfall: no
nameattribute. The field is never submitted. - Pitfall: missing or mismatched
for/id. The label then does not name the control. - Pitfall: placeholder as the only label.
- Pitfall: skipping heading levels or choosing headings by font size.
- Pitfall: validating only in the browser.
- Pitfall: color-only error indication.
- Pitfall: forgetting to clear
setCustomValidity. - Pitfall:
<div>or<span>acting as buttons or links. An<a href>goes somewhere; a<button>does something. - Pitfall: a
<button>in a form withouttypesubmits the form unexpectedly. - Pitfall: using
patternwith a trailing assumption. The whole value must match; provide visible, associated format instructions. - Pitfall:
autofocusand removal of the focus outline (outline: nonewithout a replacement).
Study tip. When a question asks how to make a form accessible, look for labels associated with controls, fieldset/legend for groups, error text linked with aria-describedby, and not relying on color or placeholders.
Study tip. If a question asks which input is not submitted, the answers are a control without name or a disabled control. A readonly control is submitted.
Study tip. "Which element is best for the main navigation?" is <nav>. "A self-contained blog post?" is <article>. Choose the element for meaning, not appearance.
Mini case study: the newsletter form that lost sign-ups
Situation. The Harbor Workshops newsletter form is built like this:
<div class="form">
<input type="text" placeholder="Email">
<div class="btn" onclick="subscribe()">Subscribe</div>
</div>Analytics show that mobile users rarely complete it, keyboard users cannot subscribe, and the server receives some entries such as abc and <script>alert(1)</script>. On invalid input, the field only turns red.
Questions.
- List the problems.
- Rewrite the markup.
- What must the server do?
Solution.
- There is no
<form>, no<label>, and noname; the placeholder is the only label;type="text"gives no email keyboard or format check; the "button" is adiv, so it cannot be reached or activated by keyboard; the error is shown with color only and is not announced; and the server accepts unvalidated data. - Use:
<form action="/api/newsletter" method="post">
<label for="newsletter-email">Email address</label>
<input id="newsletter-email" name="email" type="email" autocomplete="email" required
aria-describedby="newsletter-error">
<p id="newsletter-error" role="alert"></p>
<button type="submit">Subscribe</button>
</form>This minimal version retains native validation. If you add custom messages, use the complete submit-handling pattern above, link the message to the field, and clear the state after correction. An invalid native form does not dispatch submit.
3. The server must validate the email format and length itself, reject invalid submissions, apply rate limiting, and never trust the browser's checks. Validate input for its intended use and encode output for the context where it is displayed; escaping is not a substitute for validation. The <script> input shows that output must be escaped when displayed; React escapes text it renders by default, but the server should still validate.
Key terms
- Semantic HTML: elements chosen for their meaning.
- Landmark: a region such as
nav,main, orfooterthat assistive technology can jump to. - Label: the visible name programmatically tied to a control.
- Constraint validation: the browser's built-in checks of form values.
aria-describedby: attribute that points to descriptive text for an element.aria-invalid: attribute that marks a value as invalid.- Live region: an area whose changes are announced by screen readers.
autocomplete: attribute that identifies the purpose of a field for autofill.- Fieldset / legend: grouping element and its group caption.
Summary checklist
- I choose native semantic elements before reaching for
divplus ARIA. - I use one
h1, a logical heading outline, and landmarks. - Every control has a visible, associated
<label>; placeholders are only examples. - Each submitted control has a
name; I knowdisabledfields are not sent. - I choose the right
inputtype andautocompletevalue. - I use
required,min,max,pattern, and the Constraint Validation API correctly. - Error messages are text, specific, linked with
aria-describedby, and announced. - I test the form with the keyboard alone.
- I remember that the server must validate every submission again.
Official sources
- Meta Front-End Developer Professional Certificate (opens in a new tab)
- Course 4: HTML and CSS in Depth (opens in a new tab)
- MDN: Your first form (opens in a new tab)
- MDN: Client-side form validation (opens in a new tab)
- MDN: HTML elements reference (opens in a new tab)
- W3C WAI: Forms tutorial (opens in a new tab)
- WAI-ARIA Authoring Practices: Read Me First (opens in a new tab)
- WHATWG: form constraints and submission (opens in a new tab)
- MDN: Constraint Validation API (opens in a new tab)
- W3C WAI: accessible form notifications (opens in a new tab)
