Built In Form Validation

The browser can check most of a form before it is sent, with no JavaScript. Learn the attributes, the CSS hooks, and why none of it replaces server validation.

Concept

HTML can validate a form on its own. Add a few attributes and the browser refuses to submit an invalid form, focuses the first problem field and shows a message - all before any script runs.

It is a convenience for the reader, not a security measure. Every attribute here can be removed in the developer tools in five seconds, and a request can be sent without a browser at all. The server must validate everything again, without exception.

The attributes

AttributeChecksApplies to
requiredNot emptyMost controls
typeFormat for email, url, number, datesinput
minlength / maxlengthCharacter countText controls
min / maxValue rangeNumber, range, date, time
stepAllowed incrementsNumber, range, date, time
patternA regular expressionText style inputs

Example

<form action="/register" method="post">
  <p>
    <label for="user">Username</label>
    <input type="text" id="user" name="user"
           required minlength="3" maxlength="20"
           pattern="[a-z0-9_]+"
           aria-describedby="user-help">
    <small id="user-help">Three to twenty characters: lower case letters, digits and underscores.</small>
  </p>

  <p>
    <label for="email">Email address</label>
    <input type="email" id="email" name="email" required autocomplete="email">
  </p>

  <p>
    <label for="age">Age</label>
    <input type="number" id="age" name="age" min="16" max="120" step="1" required>
  </p>

  <p>
    <label for="start">Preferred start date</label>
    <input type="date" id="start" name="start" min="2026-09-01" max="2027-08-31">
  </p>

  <button type="submit">Create account</button>
</form>

pattern

pattern takes a JavaScript regular expression, anchored automatically at both ends - the whole value must match, so there is no need to write the start and end anchors.

<input pattern="[0-9]{6}">                    <!-- exactly six digits -->
<input pattern="[A-Z]{5}[0-9]{4}[A-Z]">      <!-- a fixed code format -->
<input pattern="[a-z0-9._%+-]+@[a-z0-9.-]+.[a-z]{2,}">
<input pattern="(+91)?[6-9][0-9]{9}">       <!-- optional country code -->

Two rules that make pattern usable rather than infuriating:

  • Always pair it with a visible hint describing the format. A reader who cannot see the pattern cannot guess it.
  • Always add a title, which browsers append to the error message.
<input type="text" name="pin"
       pattern="[0-9]{6}"
       title="Six digits"
       aria-describedby="pin-help">
<small id="pin-help">Six digits, for example 411001</small>

Resist the temptation to write a strict email pattern. Real addresses are far stranger than most regular expressions allow, and type="email" already applies a sensible check.

Styling validity

input:invalid  { border-color: #dc2626; }
input:valid    { border-color: #16a34a; }
input:required { border-left: 3px solid #1e3a8a; }

Applied naively this is hostile: an empty required field is invalid from the moment the page loads, so the whole form is outlined in red before the reader has typed anything.

:user-invalid fixes it. It matches only after the reader has interacted with the field or attempted to submit:

input:user-invalid {
  border-color: #dc2626;
  background: #fef2f2;
}
input:user-invalid + .error-message { display: block; }

Custom messages

The default messages are localised by the browser but generic. setCustomValidity replaces them:

const pin = document.getElementById("pin");

pin.addEventListener("input", () => {
  if (pin.validity.patternMismatch) {
    pin.setCustomValidity("A PIN code is exactly six digits.");
  } else {
    pin.setCustomValidity("");     // an empty string clears it
  }
});

Forgetting to clear it is a classic bug: the field stays permanently invalid because a stale custom message is still set.

The validity object

input.validity.valueMissing      // required but empty
input.validity.typeMismatch      // wrong format for the type
input.validity.patternMismatch   // failed the pattern
input.validity.tooShort          // below minlength
input.validity.tooLong           // above maxlength
input.validity.rangeUnderflow    // below min
input.validity.rangeOverflow     // above max
input.validity.stepMismatch      // not on a step boundary
input.validity.valid             // passes everything

Turning it off

<form novalidate>...</form>
<button type="submit" formnovalidate>Save draft</button>

Adding novalidate and validating in script is a legitimate choice - it gives full control over the messages and their placement, which the native bubbles do not. Keep the attributes on the fields regardless, so the validity API still works.

Accessible error messages

The native bubble disappears quickly and is not always announced. For a form that matters, show errors in the page:

<p>
  <label for="email">Email address</label>
  <input type="email" id="email" name="email" required
         aria-describedby="email-error" aria-invalid="true">
  <span id="email-error" class="error" role="alert">
    Enter an email address, for example meera@example.com
  </span>
</p>
  • aria-invalid="true" marks the field as failing.
  • aria-describedby ties the message to the field, so it is announced on focus.
  • role="alert" announces it immediately when it appears.
  • Never signal an error with colour alone. Use text and, ideally, an icon.

Important rules

  • Client side validation is never a security boundary.
  • pattern is anchored at both ends automatically.
  • required on one radio applies to the whole group.
  • min and max on a date field take ISO format values.
  • A hidden or disabled field is not validated.
  • Only the first invalid field is focused and reported.

Common mistakes

  • Trusting the browser and skipping server validation.
  • Styling :invalid instead of :user-invalid, painting an untouched form red.
  • A pattern with no visible hint.
  • Over strict email patterns rejecting valid addresses.
  • Forgetting to clear a custom validity message.
  • Relying on the native bubble as the only error indication.
  • Colour only error states.

Best practices

  • Use the native attributes first; add script only for what they cannot express.
  • Validate on the server for the same rules, always.
  • Show the format requirement before the reader types, not after they fail.
  • Use :user-invalid for styling.
  • Put errors next to the field, in text, tied with aria-describedby.
  • Summarise errors at the top of a long form and link each to its field.
  • Never clear a form because one field failed.

Practice

  1. Build a registration form using only native validation and attempt to submit it empty.
  2. Style with :invalid first, then switch to :user-invalid and compare the first impression.
  3. Add a pattern with a title and a visible hint, and check the error message.
  4. Bypass the validation in DevTools and submit an invalid value. Explain what the server must do.

Useful resources

Hand picked references for this topic
Written by Lorens Mishra

Software Engineer Notes Management System Administrator

Continue reading

All HTML notes →

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.