Back to Lab
RAXXO Studios 8 min read No time? Make it a 1 min read

Form Validation in 2026: 6 Native Constraints Before You Reach for a Library

Accessibility
8 min read
TLDR
×
  • Native constraints cover 6 common cases without a single byte of JS
  • setCustomValidity gives you full control over error text
  • :user-invalid styles errors only after the user interacts
  • Reach for a library only for cross-field and async rules

I shipped a checkout form last month with zero validation libraries and it handled everything: required fields, email shapes, password length, matching inputs, and inline error styling. The whole thing was native HTML and about 30 lines of JavaScript. Here is exactly how far the browser takes you in 2026, and the precise point where a library still earns its bytes.

The 6 Native Constraints That Cover Most Forms

Before you `npm install` anything, here are the six attributes that solve the bulk of real validation. Every one of these ships in every browser shipping today.

First, `required`. Empty field, no submit. One word. It works on text inputs, selects, checkboxes, and radios.

Second, `type`. Setting `type="email"` makes the browser check for a basic email shape. `type="url"` checks for a protocol. `type="number"` blocks non-numeric input on most keyboards and validates on submit. These are not perfect (the email check allows `a@b` which is technically valid) but they catch 90 percent of fat-finger mistakes.

Third, `minlength` and `maxlength`. A password field with `minlength="8"` refuses to submit at 7 characters and tells the user why. No counter logic needed.

Fourth, `min`, `max`, and `step` for numbers and dates. A quantity field with `min="1" max="99"` enforces the range. A date picker with `min="2026-01-01"` blocks past dates.

Fifth, `pattern`. This is a regex attribute. A postal code field with `pattern="[0-9]{5}"` enforces five digits. Pair it with the `title` attribute so the error message explains the expected format.

Sixth, `inputmode` and `autocomplete`. These do not validate, but they reduce errors before they happen. `inputmode="numeric"` pulls up the number pad on phones. `autocomplete="email"` lets the browser fill known-good data. Fewer typos means fewer validation failures.

Here is a real field from that checkout:





That block validates four ways with no script attached. The browser blocks submit, shows a bubble, and focuses the first bad field. I measured it: this covered 6 of the 8 validation cases on the form. Two cases needed JavaScript, which I will get to.

The Constraint Validation API for Custom Messages

The native bubble messages are functional but generic. "Please fill out this field" is fine. "Please match the requested format" is useless to a user staring at a postal code box. This is where the Constraint Validation API earns its place, and it is built into every input element already.

Every form control exposes a `validity` object. Read it and you get booleans like `valueMissing`, `typeMismatch`, `tooShort`, `patternMismatch`, and `rangeUnderflow`. You check which one is true and you write a message that actually helps.


input.addEventListener('invalid', () => {
  if (input.validity.valueMissing) {
    input.setCustomValidity('Email is required.');
  } else if (input.validity.typeMismatch) {
    input.setCustomValidity('That email looks off.');
  } else {
    input.setCustomValidity('');
  }
});
input.addEventListener('input', () => {
  input.setCustomValidity('');
});

The key detail people miss: you must clear the custom message on the next `input` event by calling `setCustomValidity('')`. If you forget, the field stays stuck in an invalid state even after the user fixes it. That one line is the difference between a working form and a frustrating one.

`setCustomValidity` also flips the field into an invalid state. So you can use it for rules the browser does not know. Want to reject disposable email domains? Check the value, set a custom message, and the native machinery handles the rest: the submit blocks, the bubble shows, focus moves.

You also get `checkValidity()` and `reportValidity()` on both individual fields and the whole form. `checkValidity()` returns true or false silently. `reportValidity()` does the same but also shows the bubbles and focuses the first bad field. I call `form.reportValidity()` at the top of my submit handler. If it returns false, I bail before touching the network. That single call replaced what used to be a 40-line validation loop in older codebases I have cleaned up.

The reason this matters for a one-person studio: less code means fewer bugs to chase later. I documented my whole approach to keeping dependencies lean in the Claude Blueprint, and form validation is one of the clearest examples of the browser already doing the heavy lifting.

Styling Errors With :user-invalid Instead of :invalid

For years the styling story was broken. The `:invalid` pseudo-class matches a field the moment the page loads if it is empty and required. So a fresh form shows every required field glowing red before the user types a single character. That is hostile. Everyone worked around it with JavaScript classes like `.touched` or `.dirty`.

`:user-invalid` fixes this at the CSS level. It only matches after the user has interacted with the field and then moved on, or after a submit attempt. No JavaScript flag needed. This is shipping in every current browser as of 2026, so the old workarounds are dead weight now.


input:user-invalid {
  border-color: #d33;
}
input:user-valid {
  border-color: #2a2;
}

That is the entire styling layer. Empty form on load: neutral borders. User tabs through an empty required field: red border appears. User fixes it: green border. No class toggling, no event listeners for state. I cut about 60 lines of "touched state" tracking out of a form rebuild and the behavior got better, not worse.

There is a companion pseudo-class worth knowing: `:has()`. You can style a field's wrapper based on the input inside it.


.field:has(input:user-invalid) .error-text {
  display: block;
}

That shows your custom inline error text only when the field is actually in a user-invalid state. Combine `:user-invalid` with `:has()` and you have a full error display system with zero JavaScript for the styling layer. The JS only sets messages; CSS handles all the visual state.

One gotcha: `:user-invalid` does not fire for programmatic value changes. If you set a value with JavaScript, it will not flag as user-invalid until the user touches it. Usually that is the behavior you want, but test it if you autofill fields. I keep a short list of these browser quirks the same way I keep reusable Git hooks, small things I copy into every project so I never relearn them. The pattern of writing down the gotcha once saves me an afternoon every time.

Where a Library Still Earns Its Bytes

Native validation handles single-field rules beautifully. It falls apart on two things, and these are exactly where a library still pays for its download size.

First, cross-field validation. The classic case is "confirm password must match password." The browser has no concept of one field depending on another. You can hack it with `setCustomValidity` and a manual comparison, and for one rule that is fine:


confirm.addEventListener('input', () => {
  confirm.setCustomValidity(
    confirm.value === pw.value ? '' : 'Passwords must match.'
  );
});

But when you have five interdependent rules ("if country is US, postal code is required and must be 5 digits; if shipping differs from billing, validate both blocks") the manual approach turns into spaghetti fast. A schema-based library like Zod or Valibot lets you declare the whole relationship once and validate the entire object in one call. That is genuinely better than hand-rolling it.

Second, async validation. "Is this username taken?" or "Is this discount code valid?" requires a network call. Native constraints are synchronous. You can fake async with `setCustomValidity` after a fetch resolves, but you have to manage loading states, debouncing, and race conditions yourself. A good library gives you that plumbing for free.

My rule of thumb after building dozens of forms: if your form is single-field rules plus one or two cross-field checks, stay native. The total cost is the 30 lines I mentioned at the top. If you have a multi-step form, conditional fields that appear based on earlier answers, server-side schema reuse, or three or more async checks, reach for a schema library and share the schema between client and server. That shared schema is the real payoff, not the validation itself.

The trap is reaching for the library by default. I see projects pull in 40KB of validation code to enforce a required email field. That is solved by one HTML attribute. Start native, measure what actually breaks, and add the library only at the field where the browser genuinely cannot help. Most forms never hit that line.

Bottom Line

Native form validation in 2026 covers far more than most developers assume. Six attributes (`required`, `type`, `minlength`, `pattern`, `min`/`max`, and the input hints) handle the common cases with zero JavaScript. The Constraint Validation API gives you full control over error messages through `setCustomValidity`. And `:user-invalid` finally lets you style errors at the right moment without tracking touched state by hand. Together that is a complete validation system in about 30 lines.

The library only earns its bytes for cross-field rules at scale and async checks against a server. Even then, the real win is sharing one schema between client and browser, not the validation logic itself.

I rebuild small tools constantly as a solo studio, and cutting dependencies is how I stay fast. If you want the same approach applied across a whole project, the Claude Blueprint walks through how I keep things lean from the first commit. Start native. Add code only where the browser truly cannot help. Your future self maintaining the form will thank you.

Stay in the loop
New tools, drops, and AI experiments. No spam. Unsubscribe anytime.
Back to all articles