Native Form Validation

Engineering
Share on LinkedIn Share on X Share on Reddit Share on HN Share on Bluesky

title: "Native Form Validation" slug: "web-forms-native-validation" description: "Use HTML5 constraint validation for forms: required, pattern, input types, Constraint Validation API, custom messages, and when to add JavaScript validation." datePublished: "2026-05-04" dateModified: "2026-07-17" tags:



title: "web-forms-native-validation" slug: "web-forms-native-validation" description: "" datePublished: "2026-07-17" dateModified: "2026-07-17" tags:



title: "web-forms-native-validation" slug: "web-forms-native-validation" description: "" datePublished: "2026-07-17" dateModified: "2026-07-17" tags:



title: "web-forms-native-validation" slug: "web-forms-native-validation" description: "" datePublished: "2026-07-17" dateModified: "2026-07-17" tags:



title: "web-forms-native-validation" slug: "web-forms-native-validation" description: "" datePublished: "2026-07-17" dateModified: "2026-07-17" tags:



title: "web-forms-native-validation" slug: "web-forms-native-validation" description: "" datePublished: "2026-07-17" dateModified: "2026-07-17" tags:



title: "Native Form Validation" slug: "web-forms-native-validation" description: "Use HTML5 constraint validation for forms: required, pattern, input types, Constraint Validation API, custom messages, and when to add JavaScript validation." datePublished: "2026-05-04" dateModified: "2026-07-17" tags:



Our signup form imported a 45KB validation library to check that email fields contained an @ symbol. The browser already did that with type="email". We removed the library, switched to native constraints with custom messages via the Constraint Validation API, and the form worked with JavaScript disabled while giving us styled error states when JS was available.

HTML constraint attributes

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

  <label for="age">Age</label>
  <input
    id="age"
    name="age"
    type="number"
    min="13"
    max="120"
    required
  />

  <label for="website">Website</label>
  <input
    id="website"
    name="website"
    type="url"
    placeholder="https://example.com"
  />

  <label for="username">Username</label>
  <input
    id="username"
    name="username"
    type="text"
    pattern="[a-zA-Z0-9_]{3,20}"
    title="3-20 characters: letters, numbers, underscores"
    required
  />

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

Each attribute maps to a validation constraint the browser checks on submit and on blur.

Input types with built-in validation

Type Validates
email Contains @ with domain
url Valid URL scheme
number Numeric, respects min/max/step
tel No format validation (intentionally)
date Valid date, respects min/max
file Accept attribute filters MIME types

Constraint Validation API

Every form control implements the ValidityState interface:

const input = document.getElementById('email');

input.addEventListener('input', () => {
  const valid = input.validity.valid;
  input.classList.toggle('invalid', !valid);
  input.classList.toggle('valid', valid);
});

form.addEventListener('submit', (e) => {
  if (!form.checkValidity()) {
    e.preventDefault();
    form.reportValidity(); // shows native tooltips
  }
});

Key properties on input.validity:

validity.valueMissing    // required field is empty
validity.typeMismatch    // wrong type (email without @)
validity.patternMismatch // doesn't match pattern attribute
validity.tooShort        // below minlength
validity.tooLong         // above maxlength
validity.rangeUnderflow  // number below min
validity.rangeOverflow   // number above max
validity.customError     // setCustomValidity() was called

Custom error messages

const inputs = form.querySelectorAll('input, select, textarea');

inputs.forEach((input) => {
  input.addEventListener('invalid', (e) => {
    e.preventDefault(); // suppress native tooltip

    const messages = {
      valueMissing: `${input.labels[0]?.textContent} is required`,
      typeMismatch: 'Enter a valid email address',
      patternMismatch: input.title || 'Invalid format',
      rangeUnderflow: `Minimum value is ${input.min}`,
      rangeOverflow: `Maximum value is ${input.max}`,
    };

    for (const [key, message] of Object.entries(messages)) {
      if (input.validity[key]) {
        showError(input, message);
        return;
      }
    }
  });

  input.addEventListener('input', () => {
    clearError(input);
  });
});

Call setCustomValidity('') on input to clear custom errors and allow re-validation.

Styling valid and invalid states

input:user-valid {
  border-color: #16a34a;
}

input:user-invalid {
  border-color: #dc2626;
}

input:user-invalid:focus {
  outline-color: #dc2626;
}

The :user-valid and :user-invalid pseudo-classes apply only after the user interacts with the field, avoiding red borders on untouched required fields at page load.

Cross-field validation

Native validation can't compare two fields. Use JavaScript for password confirmation:

const password = document.getElementById('password');
const confirm = document.getElementById('confirm-password');

function validateMatch() {
  if (confirm.value && confirm.value !== password.value) {
    confirm.setCustomValidity('Passwords do not match');
  } else {
    confirm.setCustomValidity('');
  }
}

password.addEventListener('input', validateMatch);
confirm.addEventListener('input', validateMatch);

novalidate and server-side validation

Add novalidate to the form when building a fully custom validation UI:

<form novalidate>

This disables native tooltips but keeps the Constraint Validation API functional — you still call checkValidity() programmatically.

Server-side validation is non-negotiable. Client validation improves UX; it does not protect against crafted requests.

Server-side mirror

Mirror client constraints on the server — never trust the browser:

def validate_signup(data):
    errors = {}
    if not data.get('email') or '@' not in data['email']:
        errors['email'] = 'Valid email required'
    if not re.match(r'^[a-zA-Z0-9_]{3,20}$', data.get('username', '')):
        errors['username'] = '3-20 alphanumeric characters'
    return errors

Return field-level errors in a structured format the client can display next to each input.

Live regions for screen readers

When custom validation UI replaces native tooltips, announce errors with ARIA live regions:

<div role="alert" aria-live="polite" id="email-error"></div>

Integration with design systems

Design system inputs should expose validity state to CSS:

.ds-input:user-invalid { border-color: var(--error); }
.ds-input:user-valid { border-color: var(--success-subtle); }

Pair with aria-invalid toggled in invalid event listener—native and ARIA stay synchronized.

Server-side mirror

Duplicate rules server-side—never trust client validation alone. Share constraint definitions via OpenAPI or shared Zod schema code-generated to HTML attributes where possible.

Progressive enhancement without JS

Without JS, native validation still works on submit—ensure form works with full page POST for critical flows (login, payment) even in enhanced SPA mode.

Constraint Validation API events

Listen invalid on form, set custom message with setCustomValidity, call reportValidity() on submit. :user-invalid pseudo-class styles errors only after interaction — better UX than red on first keystroke.

Share rules with Zod

Generate HTML attributes from Zod schema — single source for client native and server validation. minLength, pattern, type=email mirror Zod constraints.

Practical follow-through (1)

Ship the smallest vertical slice first — one route, one widget, one index configuration — with rollback documented before expanding scope. Baseline the user-visible metric this work protects (latency, recall, conversion, task success rate) for seven days before change and seven days after in your largest market.

Compare canary p75 to control before full rollout. Exercise edge paths manually: refresh, back navigation, double-submit, offline mode, and keyboard-only flows. When assumptions change — traffic doubles, vendor upgrades, org restructure — revisit whether the original design still fits; quiet periods hide drift until the next incident.

Practical follow-through (2)

Ship the smallest vertical slice first — one route, one widget, one index configuration — with rollback documented before expanding scope. Baseline the user-visible metric this work protects (latency, recall, conversion, task success rate) for seven days before change and seven days after in your largest market.

Compare canary p75 to control before full rollout. Exercise edge paths manually: refresh, back navigation, double-submit, offline mode, and keyboard-only flows. When assumptions change — traffic doubles, vendor upgrades, org restructure — revisit whether the original design still fits; quiet periods hide drift until the next incident.

Practical follow-through (3)

Ship the smallest vertical slice first — one route, one widget, one index configuration — with rollback documented before expanding scope. Baseline the user-visible metric this work protects (latency, recall, conversion, task success rate) for seven days before change and seven days after in your largest market.

Compare canary p75 to control before full rollout. Exercise edge paths manually: refresh, back navigation, double-submit, offline mode, and keyboard-only flows. When assumptions change — traffic doubles, vendor upgrades, org restructure — revisit whether the original design still fits; quiet periods hide drift until the next incident.

Practical follow-through (4)

Ship the smallest vertical slice first — one route, one widget, one index configuration — with rollback documented before expanding scope. Baseline the user-visible metric this work protects (latency, recall, conversion, task success rate) for seven days before change and seven days after in your largest market.

Compare canary p75 to control before full rollout. Exercise edge paths manually: refresh, back navigation, double-submit, offline mode, and keyboard-only flows. When assumptions change — traffic doubles, vendor upgrades, org restructure — revisit whether the original design still fits; quiet periods hide drift until the next incident.

Practical follow-through (5)

Ship the smallest vertical slice first — one route, one widget, one index configuration — with rollback documented before expanding scope. Baseline the user-visible metric this work protects (latency, recall, conversion, task success rate) for seven days before change and seven days after in your largest market.

Compare canary p75 to control before full rollout. Exercise edge paths manually: refresh, back navigation, double-submit, offline mode, and keyboard-only flows. When assumptions change — traffic doubles, vendor upgrades, org restructure — revisit whether the original design still fits; quiet periods hide drift until the next incident.

Resources

Frequently asked questions

What is the main production risk with web forms native validation?

Teams ship without field measurement—web forms native validation failures appear as silent UX regressions, cost drift, or audit findings rather than clear errors.

When should we prioritize web forms native validation?

Prioritize when user research, CrUX, support tickets, or compliance requirements show pain on critical paths—not when a checklist mentions it abstractly.

How do we validate web forms native validation changes?

Baseline RUM before changes, compare p75 after deploy, and keep rollback via feature flags or cache purge documented in the PR.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →