Most HTML forms I encounter in production use three input types: text, password, and submit. That's it. Meanwhile, HTML has had rich built-in input types, constraint validation, and accessibility features for years — all available with no JavaScript and no npm install. Let's use them.

Input Types Worth Knowing

Each input type does real work for you: it triggers the right mobile keyboard, provides built-in validation, and communicates intent to assistive technology. Here are the ones that come up constantly in real projects:

html
<!-- Email: validates format, shows email keyboard on mobile -->
<input type="email" name="email" autocomplete="email">

<!-- Phone: shows numeric keypad on mobile -->
<input type="tel" name="phone" autocomplete="tel" pattern="[0-9]{10,15}">

<!-- Number: spin buttons, min/max/step validation -->
<input type="number" name="quantity" min="1" max="99" step="1" value="1">

<!-- Date: native date picker (no library needed) -->
<input type="date" name="birthdate" min="1900-01-01" max="2026-12-31">

<!-- Range: slider with min/max/step -->
<input type="range" name="volume" min="0" max="100" step="5" value="50">

<!-- Color: native color picker -->
<input type="color" name="theme_color" value="#0066cc">

<!-- File: single or multiple file upload -->
<input type="file" name="resume" accept=".pdf,.doc,.docx">
<input type="file" name="photos" accept="image/*" multiple>
Pro tip: type="date" returns the value in YYYY-MM-DD format regardless of the user's locale — which is exactly what you want when sending to a server. Never parse dates from type="text" inputs if you can avoid it.

Label Association — Do It Right

Labels are not optional decoration. They're the primary way screen readers identify inputs, and clicking a label should focus the associated input. There are two valid approaches:

html
<!-- Method 1: for/id association (most common, most flexible) -->
<label for="email">Email address</label>
<input type="email" id="email" name="email">

<!-- Method 2: wrapping label (no id needed) -->
<label>
  Email address
  <input type="email" name="email">
</label>

<!-- Wrong: placeholder is NOT a label -->
<input type="email" name="email" placeholder="Email address">
<!-- placeholder disappears when the user starts typing — terrible UX for screen readers -->

The for/id approach is usually preferable because it keeps labels and inputs decoupled in the DOM, which is easier to style. Never rely on placeholder as a label substitute — it fails users with cognitive disabilities and disappears the moment someone starts typing. The W3C WAI labels tutorial and WebAIM's form control guidance both cover the accessibility rationale in more depth.

Built-In Constraint Validation

HTML's built-in constraint validation runs before form submission and is genuinely powerful. You get a lot of useful behavior for free:

html
<form>
  <!-- required: field must have a value -->
  <label for="fullname">Full name</label>
  <input type="text" id="fullname" name="fullname" required>

  <!-- minlength/maxlength: character count constraints -->
  <label for="username">Username (3–20 chars)</label>
  <input type="text" id="username" name="username"
         minlength="3" maxlength="20" required>

  <!-- pattern: regex-based validation -->
  <label for="postcode">UK Postcode</label>
  <input type="text" id="postcode" name="postcode"
         pattern="[A-Z]{1,2}[0-9][0-9A-Z]?s?[0-9][A-Z]{2}"
         title="Enter a valid UK postcode (e.g. SW1A 1AA)"
         required>

  <!-- min/max on date -->
  <label for="checkin">Check-in date</label>
  <input type="date" id="checkin" name="checkin"
         min="2026-04-16" required>

  <button type="submit">Submit</button>
</form>
  • required. Field must be non-empty. On checkboxes, it must be checked.
  • pattern. A regex the value must match. Always add a title attribute — it becomes the validation tooltip text and gives users a hint about the expected format.
  • minlength / maxlength. Character count for text inputs. maxlength silently truncates input; minlength only validates on submit.
  • min / max. Numeric or date bounds. Works on number, date, range, and time inputs.
  • step. Defines valid increments. step="0.01" on a currency field allows cents. step="any" disables the step check entirely.

fieldset and legend for Grouping

When you have a group of related inputs — especially radio buttons or checkboxes — <fieldset> and <legend> group them semantically. Screen readers read the legend before each input in the group, so users always know which question a radio button answers.

html
<form>
  <fieldset>
    <legend>Preferred contact method</legend>

    <label>
      <input type="radio" name="contact" value="email" checked>
      Email
    </label>
    <label>
      <input type="radio" name="contact" value="phone">
      Phone
    </label>
    <label>
      <input type="radio" name="contact" value="post">
      Post
    </label>
  </fieldset>

  <fieldset>
    <legend>Notification preferences</legend>

    <label>
      <input type="checkbox" name="notify_new_posts" value="1">
      New articles
    </label>
    <label>
      <input type="checkbox" name="notify_replies" value="1">
      Replies to my comments
    </label>
  </fieldset>
</form>

The Constraint Validation API

The Constraint Validation API gives you JavaScript access to the same validation logic the browser uses natively. This lets you trigger validation programmatically and set custom error messages without blocking submit:

js
const usernameInput = document.getElementById('username');

// Check if a single field is valid
console.log(usernameInput.checkValidity()); // true or false
console.log(usernameInput.validity.tooShort); // true if below minlength
console.log(usernameInput.validity.patternMismatch); // true if pattern fails

// Set a custom error message (shows in the browser's native validation tooltip)
usernameInput.setCustomValidity('That username is already taken.');
usernameInput.reportValidity(); // triggers the tooltip immediately

// Clear a custom error (important — once set, it sticks until cleared)
usernameInput.setCustomValidity('');

// Validate on the fly as the user types (async example — username availability)
usernameInput.addEventListener('input', async () => {
  const value = usernameInput.value;
  if (value.length < 3) return; // let minlength handle this

  const response = await fetch(`/api/check-username?q=${encodeURIComponent(value)}`);
  const { available } = await response.json();

  usernameInput.setCustomValidity(available ? '' : 'Username is already taken.');
});

Notice the critical detail: once you call setCustomValidity() with a non-empty string, the field is permanently invalid until you clear it by calling setCustomValidity(''). Forgetting the clear step is the most common bug when using this API.

novalidate for Custom JS Validation

If you're building a custom validation UI with styled error messages (not the browser's native tooltips), add novalidate to the form. This disables browser-native validation while keeping the Constraint Validation API available for your own checks:

html
<form id="signup-form" novalidate>
  <div class="field">
    <label for="signup-email">Email</label>
    <input type="email" id="signup-email" name="email" required>
    <span class="error" aria-live="polite"></span>
  </div>

  <div class="field">
    <label for="signup-password">Password</label>
    <input type="password" id="signup-password" name="password" minlength="8" required>
    <span class="error" aria-live="polite"></span>
  </div>

  <button type="submit">Create account</button>
</form>
js
const form = document.getElementById('signup-form');

form.addEventListener('submit', (e) => {
  e.preventDefault();

  let isValid = true;

  form.querySelectorAll('input').forEach((input) => {
    const errorEl = input.nextElementSibling;

    if (!input.checkValidity()) {
      isValid = false;
      errorEl.textContent = input.validationMessage;
      input.setAttribute('aria-invalid', 'true');
    } else {
      errorEl.textContent = '';
      input.removeAttribute('aria-invalid');
    }
  });

  if (isValid) {
    form.submit();
  }
});

autocomplete and aria-describedby

Two attributes that significantly improve form UX and are easy to overlook:

html
<!-- autocomplete: tells browsers/password managers what the field is for -->
<input type="text"     name="fname"    autocomplete="given-name">
<input type="text"     name="lname"    autocomplete="family-name">
<input type="email"    name="email"    autocomplete="email">
<input type="tel"      name="phone"    autocomplete="tel">
<input type="password" name="password" autocomplete="current-password">
<input type="password" name="new_pass" autocomplete="new-password">
<input type="text"     name="cc"       autocomplete="cc-number">

<!-- aria-describedby: links an input to a hint or error message -->
<label for="pass">Password</label>
<input
  type="password"
  id="pass"
  name="password"
  minlength="8"
  aria-describedby="pass-hint"
  required
>
<span id="pass-hint">Must be at least 8 characters.</span>

The autocomplete attribute uses standardised token values defined by the WHATWG spec. When you use the correct token, browsers and password managers can auto-fill fields reliably. aria-describedby associates a hint or error message with an input — screen readers read the description after the field label, which makes constraints audible before the user even starts typing.

A Complete Accessible Login Form

Here's everything combined — a login form that works for keyboard users, screen reader users, and mobile users, using only HTML and a small amount of JavaScript:

html
<form id="login-form" novalidate>
  <h2>Sign in</h2>

  <div class="field">
    <label for="login-email">Email address</label>
    <input
      type="email"
      id="login-email"
      name="email"
      autocomplete="email"
      aria-describedby="email-error"
      required
    >
    <span id="email-error" class="error" aria-live="polite" role="alert"></span>
  </div>

  <div class="field">
    <label for="login-password">Password</label>
    <input
      type="password"
      id="login-password"
      name="password"
      autocomplete="current-password"
      aria-describedby="password-error"
      required
    >
    <span id="password-error" class="error" aria-live="polite" role="alert"></span>
    <a href="/forgot-password">Forgot password?</a>
  </div>

  <label class="inline">
    <input type="checkbox" name="remember" value="1">
    Keep me signed in for 30 days
  </label>

  <button type="submit">Sign in</button>
</form>

Key points: aria-live="polite" on error spans means screen readers will announce errors when they appear without interrupting whatever the user is currently hearing. role="alert" reinforces this for older screen readers. The autocomplete values match what password managers expect, so autofill works correctly on first try.

Tools to Help

When building and testing HTML forms, HTML Validator catches structural mistakes like missing labels and duplicate IDs before users do. For general HTML cleanup, HTML Formatter keeps your markup readable. The MDN input element reference is the most complete documentation for all input types and their attributes.

Wrapping Up

HTML forms have far more capability built in than most projects use. Choosing the right input type gets you mobile keyboards, validation, and semantic meaning for free. Proper label association and aria-describedby make forms navigable without a mouse. The Constraint Validation API gives you JavaScript hooks into native validation without fighting the browser. Put these pieces together and you get forms that work for everyone — before you write a single line of custom validation logic.