Native Form Validation

WebHTMLFormsFrontend
Share on LinkedIn

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>

Measuring success in production

Deploy changes behind feature flags when possible so you can compare metrics between control and treatment groups. Use Real User Monitoring to capture performance data from actual devices and network conditions — lab tools alone miss the long tail of user experiences. Set up alerts for regressions: a 10% LCP increase week-over-week warrants investigation before it hits CrUX.

Document your baseline metrics before making changes. Performance work without measurement is guesswork. Share results with the team — concrete numbers ("LCP improved 800ms on mobile") build support for continued investment in web performance and reliability.

Review changes quarterly. Browser updates, new API support, and traffic pattern shifts can obsolete previous optimizations or create new opportunities. What worked in 2024 may not be the best approach in 2026.

Additional production considerations

Teams often underestimate the maintenance cost of performance optimizations. Automate what you can: CI bundle budgets, Lighthouse CI on PRs, and RUM dashboards that alert on regressions. Manual audits don't scale past a handful of pages.

Security and performance intersect more than teams expect. Third-party scripts that hurt INP also expand your attack surface. Self-hosting fonts and critical assets reduces both latency and supply-chain risk. Review every external dependency quarterly — remove what you no longer need.

Accessibility and performance share goals: semantic HTML helps screen readers and gives the browser better rendering hints. Native elements like dialog, popover, and details reduce JavaScript while improving accessibility. Prefer platform features over custom implementations when they meet your requirements.

Mobile users dominate traffic for most sites. Test on real mid-tier Android hardware, not just desktop Chrome. Simulated throttling in DevTools approximates network conditions but not CPU constraints. A fix that helps desktop may be invisible on mobile if the bottleneck is JavaScript execution, not network.

Collaborate with backend teams on TTFB and API response times. Frontend optimizations can't fix a 2-second server response. Set SLAs for API endpoints that feed critical pages and measure them in the same RUM pipeline as Core Web Vitals.

Resources

Frequently asked questions

When should I rely on native validation versus a JavaScript library?

Use native validation for standard constraints — required fields, email format, number ranges, URL patterns, and maxlength. It works without JavaScript, provides accessible error messages via the browser, and blocks form submission automatically. Add JavaScript validation only for cross-field rules (password confirmation), async checks (username availability), or complex business logic that HTML attributes cannot express.

How do I customize the browser's default validation messages?

Listen for the invalid event on each input and call setCustomValidity() with your message. Clear it with setCustomValidity('') on input events so the field re-validates as the user types. Never suppress native validation entirely unless you replicate all accessibility behavior in your custom UI.

Does native validation work with server-side rendering and progressive enhancement?

Yes. Native validation is the progressive enhancement baseline. Forms with required, type, pattern, and min/max attributes validate in any browser without JavaScript. Server-side validation remains mandatory — client validation is a UX improvement, not a security control. Always validate and sanitize on the server.

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 →