Front-End · 11 min read · Updated

HTML Form Best Practices: Labels, Validation and Accessible Errors

Build HTML forms that are easy to fill in: proper labels, the right input types, autocomplete, built-in validation and error messages everyone can perceive.

Html form best practices: free man filling form
Photo via Rawpixel (CC0)

Forms are where a website does business: enquiries, bookings, sign-ups, checkouts. A beautiful page with a frustrating form loses people at the last step. The good news is that most form problems are solved by plain, correct HTML – before you write any JavaScript or pick a framework.

This guide covers HTML form best practices on the code side: markup, validation and error messages. For the design side – field order, layout and wording – read Form Design UX. If you need a refresher on form elements, start with the HTML forms lesson.

1. Every field needs a real label

A <label> tells everyone what a field is for. Screen readers announce it when the field gets focus, and clicking it moves focus into the field – a bigger target for touch users.

<!-- Explicit: for matches the input's id -->
<label for="full-name">Full name</label>
<input id="full-name" name="name" autocomplete="name">

<!-- Implicit: input inside the label -->
<label>
  Email address
  <input type="email" name="email" autocomplete="email">
</label>

Watch out: A placeholder is not a label. It disappears as soon as someone types, it is usually low contrast, and people with memory or attention difficulties lose the instruction. Use placeholders only for short example formats, if at all.

Group related controls, such as radio buttons, with <fieldset> and <legend>. The legend gives the group a name, so a screen reader says “Room type, Deluxe, radio button” instead of just “Deluxe”.

<fieldset>
  <legend>Room type</legend>
  <label><input type="radio" name="room" value="standard" required> Standard</label>
  <label><input type="radio" name="room" value="deluxe"> Deluxe</label>
  <label><input type="radio" name="room" value="sea-view"> Sea view</label>
</fieldset>

2. Choose the right input type

The type attribute changes the keyboard on phones, adds built-in validation and helps browsers autofill. It costs nothing to get right.

DataMarkupWhy
Emailtype="email"Email keyboard with @; basic format check
Phone numbertype="tel"Phone keypad; no format forced, since numbers vary
Websitetype="url"URL keyboard; must include a scheme like https://
Quantity, agetype="number" with min/maxNumeric keypad and range checking
PIN code, OTP, card numbertype="text" inputmode="numeric"Number keypad without spinner buttons or number rounding
Date of traveltype="date"Native date picker
Passwordtype="password"Masked input; works with password managers
Search boxtype="search"Search key on mobile keyboards

Tip: Use type="number" only for real quantities you might add or subtract. A PIN code like 605001 or an OTP is an identifier, not a quantity – leading zeros matter and spinner arrows make no sense – so use text with inputmode="numeric".

3. Help the browser autofill

The autocomplete attribute tells the browser exactly what a field expects, so it can fill saved details correctly. It also helps meet WCAG 2.2 success criterion 1.3.5, Identify Input Purpose.

  • Names: name, or given-name and family-name if you truly need them separate.
  • Contact: email, tel.
  • Address: street-address (or address-line1, address-line2), address-level2 for city, address-level1 for state, postal-code, country-name.
  • Login: username, current-password; sign-up: new-password.
  • OTP: one-time-code, which lets phones offer the code from an SMS.

Avoid autocomplete="off" on login and address fields. It makes life harder for people who rely on saved details, and browsers may ignore it on login forms anyway.

4. Use built-in validation first

HTML can check many things without JavaScript: required, minlength, maxlength, min, max, pattern and the type itself. The browser blocks submission and shows a message if a rule fails.

<label for="pin">PIN code</label>
<input id="pin" name="pin" inputmode="numeric" autocomplete="postal-code"
       required pattern="[1-9][0-9]{5}" maxlength="6">

<label for="guests">Guests</label>
<input id="guests" name="guests" type="number" min="1" max="10" value="2">

To style invalid fields, prefer :user-invalid over :invalid. :invalid matches immediately, so an empty required field looks “wrong” before the user has typed anything. :user-invalid waits until the user has interacted with the field or tried to submit. It has been Baseline since November 2023.

input:user-invalid {
  border-color: #b91c1c;
  outline: 2px solid #b91c1c;
}

Watch out: Client-side validation is for convenience only. Anyone can bypass it, so always validate again on the server. The PHP lesson on handling forms with GET and POST shows how.

5. Write error messages people can act on

Browser default messages are generic and vary between browsers. For important forms, show your own messages, and make sure they work for everyone:

  • Say what to do, not just what is wrong. “Enter a 6-digit PIN code, like 605001” beats “Invalid input”. See UX Writing: Microcopy.
  • Put the message next to the field and connect it with aria-describedby, so screen readers read it along with the label.
  • Mark the field with aria-invalid="true" while it has an error, and remove it when fixed.
  • Do not rely on colour alone. Use text and an icon as well as red, for people with colour vision deficiency.
  • Move focus to the first invalid field on submit, or to an error summary at the top for long forms.
  • Keep what the user typed. Never clear the whole form because one field failed.

Try it: an accessible booking form

This demo turns off the browser’s pop-up messages with novalidate, but still uses the built-in rules through the Constraint Validation API (checkValidity() and validity). Errors appear next to each field, are linked with aria-describedby, and focus moves to the first problem. Submit it empty, then fix the fields one by one.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Accessible form demo</title>
<style>
  body { font-family: system-ui, sans-serif; margin: 20px; max-width: 420px; }
  .field { margin-bottom: 14px; }
  label { display: block; font-weight: 600; margin-bottom: 4px; }
  .hint { color: #475569; font-size: 0.9rem; margin: 0 0 4px; }
  input { width: 100%; box-sizing: border-box; padding: 9px; font: inherit; border: 1px solid #94a3b8; border-radius: 6px; }
  input[aria-invalid="true"] { border: 2px solid #b91c1c; }
  .error { color: #b91c1c; font-size: 0.9rem; margin: 4px 0 0; }
  .error:empty { display: none; }
  button { padding: 10px 18px; font: inherit; background: #1d4ed8; color: #fff; border: 0; border-radius: 6px; }
  #done { color: #166534; font-weight: 600; }
</style>
</head>
<body>
  <h1>Book a heritage walk</h1>
  <form id="booking" novalidate>
    <div class="field">
      <label for="name">Full name</label>
      <input id="name" name="name" autocomplete="name" required aria-describedby="name-err">
      <p class="error" id="name-err"></p>
    </div>
    <div class="field">
      <label for="email">Email</label>
      <input id="email" name="email" type="email" autocomplete="email" required aria-describedby="email-err">
      <p class="error" id="email-err"></p>
    </div>
    <div class="field">
      <label for="pin">PIN code</label>
      <p class="hint" id="pin-hint">6 digits, for example 605001</p>
      <input id="pin" name="pin" inputmode="numeric" autocomplete="postal-code" required pattern="[1-9][0-9]{5}" maxlength="6" aria-describedby="pin-hint pin-err">
      <p class="error" id="pin-err"></p>
    </div>
    <button type="submit">Book now</button>
    <p id="done" role="status"></p>
  </form>
<script>
  const form = document.getElementById('booking');
  const messages = {
    name: { valueMissing: 'Enter your full name.' },
    email: { valueMissing: 'Enter your email address.', typeMismatch: 'Enter an email like [email protected].' },
    pin: { valueMissing: 'Enter your PIN code.', patternMismatch: 'Enter a 6-digit PIN code, like 605001.' }
  };

  function check(input) {
    const err = document.getElementById(input.id + '-err');
    if (input.validity.valid) {
      input.removeAttribute('aria-invalid');
      err.textContent = '';
      return true;
    }
    const rules = messages[input.name];
    const key = Object.keys(rules).find(k => input.validity[k]);
    input.setAttribute('aria-invalid', 'true');
    err.textContent = '\u26A0 ' + (key ? rules[key] : 'Check this field.');
    return false;
  }

  form.addEventListener('submit', (e) => {
    e.preventDefault();
    const inputs = [...form.querySelectorAll('input')];
    const invalid = inputs.filter(input => !check(input));
    const done = document.getElementById('done');
    if (invalid.length) {
      done.textContent = '';
      invalid[0].focus();
    } else {
      done.textContent = 'Thank you! Your walk is booked (demo only, nothing was sent).';
    }
  });

  form.addEventListener('input', (e) => {
    if (e.target.getAttribute('aria-invalid') === 'true') check(e.target);
  });
</script>
</body>
</html>

Notice the timing: errors appear on submit, and once a field is marked invalid it re-checks as the user types, so the message disappears as soon as the problem is fixed. Checking on every keystroke from the very start would shout “invalid email” while someone is still typing it.

Error summaries for long forms

On a long form – a job application or a multi-section booking – individual messages can be far down the page and easy to miss. Add an error summary at the top when submission fails:

  1. Show a heading such as “There are 2 problems with your booking” in a container placed before the form fields.
  2. List each problem as a link that points to the field’s id, for example <a href="#pin">Enter a 6-digit PIN code</a>, so selecting it jumps straight to the field.
  3. Move keyboard focus to the summary (give it tabindex="-1" so it can receive focus), so screen reader users hear about the problems immediately.
  4. Keep the inline messages too – the summary is an index, not a replacement.

For multi-step forms, validate each step before moving on, and show the step number and total, such as “Step 2 of 3”. People are much more willing to continue when they know how much is left.

6. Small details that add up

  • Buttons: use a real <button type="submit"> with a clear verb – “Book now”, “Send enquiry” – not “Submit”. Inside a form, a button without a type is a submit button, so give other buttons type="button".
  • Mark optional fields rather than every required one, if most fields are required – or better, remove optional fields you do not need.
  • Ask for less. Every extra field lowers completion. Does a contact form really need a phone number and an address?
  • Visible focus: never remove the focus outline without a clear replacement. Keyboard users need to see where they are.
  • Size and spacing: fields at least 44px tall and a base font size of 16px on mobile help touch users and prevent some phones zooming in.
  • Prevent double submits by disabling the button while the request is in progress, and show a clear success message afterwards.
  • Use name on every field. Without it, the value is not sent to the server at all.

HTML form best practices: pre-launch checklist

  1. Every input has a visible label connected with for/id or wrapping.
  2. Radio and checkbox groups use fieldset and legend.
  3. Input types, inputmode and autocomplete match the data.
  4. You can complete and submit the form using only the keyboard.
  5. Errors are in text, next to the field, linked with aria-describedby, and focus moves to the first one.
  6. The server validates everything again and returns friendly messages.
  7. You tested it on a phone, with autofill, and with a screen reader such as NVDA, VoiceOver or TalkBack.

Using a framework? The same HTML form best practices apply. Bootstrap forms provides styles for validation states, jQuery forms and validation covers older codebases, and React forms shows controlled inputs. For the wider accessibility picture, see Web Accessibility Basics (WCAG).

Frequently asked questions

Is a placeholder enough instead of a label?

No. Placeholders disappear when you type, are often low contrast and are not reliably announced as labels. Use a visible label element for every field.

Should I use HTML validation or JavaScript validation?

Start with HTML attributes such as required, type and pattern. Add JavaScript to show clearer, accessible messages. Always validate again on the server, because client-side checks can be bypassed.

What is the difference between :invalid and :user-invalid?

:invalid matches as soon as a value breaks a rule, even on page load. :user-invalid only matches after the user has interacted with the field or tried to submit, which avoids showing errors too early.

Why use inputmode instead of type="number" for a PIN code?

type=”number” is meant for quantities. It can show spinner arrows and treats the value as a number. inputmode=”numeric” on a text input shows a number keypad while keeping the value as text.

How do screen readers know about my error message?

Connect the message to the input with aria-describedby and set aria-invalid=”true” on the input. Moving focus to the field on submit makes the screen reader announce the label and the error.

Further reading

Keep reading

All articles