Skip to content

Accessible HTML and Maintainable CSS

Step 14 of 45

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"). In chapter 1 you wrote a first semantic page. Now you go deeper into the part of HTML that lets visitors do something: forms. 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

  1. Build a form with form, label, input, select, textarea, fieldset, legend, and button.
  2. Choose the correct input type and attributes (required, min, max, pattern, autocomplete, inputmode).
  3. Connect every control to a visible label and explain why placeholders are not labels.
  4. Use native constraint validation and the Constraint Validation API, and explain why the server must validate too.
  5. Present validation errors accessibly with aria-describedby, aria-invalid, and live regions.
  6. Choose between GET and POST, and explain what action, method, and name do.
  7. Apply landmark and text-level semantic elements and a correct heading structure.
  8. 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. A <button> is focusable, activated by Enter and Space, announced as a button, and submits forms. 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 adds meaning only when HTML cannot.

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, one per page
<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> breaks navigation 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>
  • action is the URL that receives the data; method is the HTTP method.
  • name is the key the data is sent under (name=Amira). A control without a name is not submitted, a frequent surprise.
  • id links the control to its <label for="..."> and to CSS or JavaScript.
  • A <button> inside a form defaults to type="submit". Use type="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 visible in the URL; the browser warns before resubmitting

In React applications you will usually intercept the submit event and call fetch yourself, but 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 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 a screen reader reads with each option. 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 Numeric keyboard, 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: cannot be edited but is submitted.
  • 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}" title="Three capital letters, a dash, four digits, for example HRB-2026">
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

Pseudo-classes let CSS react: :valid, :invalid, :required, :user-invalid (applies only after the user has interacted, which avoids red fields on a page that has just loaded). Prefer :user-invalid over :invalid for styling error states where it is supported by your target browsers.

To turn off native validation, for example because you handle it in JavaScript, add novalidate to the form. Do this only if you provide your own complete validation and messages.

The Constraint Validation API

JavaScript can read and customize validation:

const seats = document.querySelector("#seats");

seats.addEventListener("input", () => {
  if (seats.validity.rangeOverflow) {
    seats.setCustomValidity("This workshop has at most 6 seats per booking.");
  } else {
    seats.setCustomValidity("");   // an empty string means "valid"
  }
});

form.addEventListener("submit", (event) => {
  if (!form.checkValidity()) {
    event.preventDefault();
    form.reportValidity();         // shows the browser's messages
  }
});

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 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>

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" makes screen readers announce them without moving focus.

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 in the browser, and show an error for the email.

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 action="/api/bookings" method="post" novalidate>
    <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 type="radio" name="session" value="morning" checked> Morning</label>
      <label><input 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>
  <p id="status" role="status"></p>
</main>

Step 2: Validate on submit with clear messages.

const form = document.querySelector("form");
const email = document.querySelector("#email");

form.addEventListener("submit", (event) => {
  const problem = email.validity.valueMissing
    ? "Enter your email address."
    : email.validity.typeMismatch
      ? "Enter an email address such as name@example.com."
      : "";

  if (problem) {
    event.preventDefault();
    let error = document.querySelector("#email-error");
    if (!error) {
      error = document.createElement("p");
      error.id = "email-error";
      email.after(error);
      email.setAttribute("aria-describedby", "email-hint email-error");
    }
    error.textContent = `Error: ${problem}`;
    email.setAttribute("aria-invalid", "true");
    email.focus();
  }
});

Because the form uses novalidate, the script is responsible for reporting every problem. In a production form you would loop over all the fields and write a summary. If the message is later fixed, the script should remove the error and aria-invalid.

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 the min/max checks.
  • 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 versus custom JavaScript validation

Native HTML Custom JS (Constraint Validation API)
Setup Attributes only Code needed
Messages Browser wording, varies by language and browser Fully controllable
Rules Simple (required, range, pattern) Any rule (two fields match, server check)
Accessibility Good defaults You must link messages and manage focus
Best for Most basic forms Rules and messages beyond native ones

disabled versus readonly

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 exam tips

  • Pitfall: no name attribute. 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. A <a> goes somewhere; a <button> does something.
  • Pitfall: a <button> in a form without type submits the form unexpectedly.
  • Pitfall: using pattern with a trailing assumption. The whole value must match; add a title that explains the format.
  • Pitfall: autofocus and removal of the focus outline (outline: none without a replacement).

Exam 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.

Exam 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.

Exam 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.

  1. List the problems.
  2. Rewrite the markup.
  3. What must the server do?

Solution.

  1. There is no <form>, no <label>, and no name; the placeholder is the only label; type="text" gives no email keyboard or format check; the "button" is a div, 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.
  2. 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>

A script writes "Error: enter an email address such as name@example.com." into the alert paragraph and sets aria-invalid="true" when validation fails. 3. The server must validate the email format and length itself, reject or escape unexpected content, apply rate limiting, and never trust the browser's checks. 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, or footer that 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 div plus 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 know disabled fields are not sent.
  • I choose the right input type and autocomplete value.
  • 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