form validation

HTML5 Form Validation — Live Demos

5 examples · native HTML + CSS

The UX problem: :invalid fires on page load

Both forms below are identical HTML. The left uses :invalid (what most tutorials show). The right uses :user-invalid (the fix). Notice the left form already shows red borders before you've typed anything.

❌ Using :invalid (turns red on load)
✅ Using :user-invalid (fires after interaction)
--:--:--Interact with the right-side fields and see :user-invalid fire only after you leave

The one-line CSS fix

/* ❌ Before — turns red on page load */ input:invalid { border-color: red; } /* ✅ After — only fires after user interaction */ input:user-invalid { border-color: red; } input:user-valid { border-color: green; }

All HTML validation attributes — live

Each field uses a different constraint. Use :user-invalid styling. Try valid and invalid values and observe the browser behavior.

Must contain @ and a domain
Must start with https:// or http://
Only dates in 2026 allowed
0 / 200
Try 4.7 — stepMismatch fires
--:--:--Interact with fields to see which ValidityState property fires

All 11 ValidityState properties — live explorer

Type in the field below. The ValidityState grid updates in real time to show which properties are true. The active (red) pill shows what's triggering the invalid state.

red = true (invalid)   green = valid flag
--:--:--Type in the field above to explore ValidityState properties

Reading all 11 properties

const v = input.validity; v.valid // true = all constraints pass v.valueMissing // required + empty v.typeMismatch // wrong format for type (email, url) v.patternMismatch // doesn't match pattern attribute v.tooShort // shorter than minlength v.tooLong // longer than maxlength v.rangeUnderflow // value < min v.rangeOverflow // value > max v.stepMismatch // not a multiple of step v.badInput // browser can't parse the input v.customError // setCustomValidity() was called

CSS-only inline error messages with :has() + :user-invalid

The error and success messages below appear and disappear using pure CSS — no JavaScript event listeners. The label color also changes. Watch the log for zero JS events.

Please enter a valid email address (e.g. [email protected]) ✓ Looks good!
3–20 characters, letters, numbers and underscores only ✓ Username available!
Age must be between 18 and 99 ✓ Valid age
✓ Zero JavaScript for show/hide
--:--:--Interact with the fields — error messages appear via pure CSS :has()

The CSS pattern

/* Error hidden by default */ .has-err { display: none; color: red; } .has-ok { display: none; color: green; } /* :has() shows/hides based on child input state */ .has-field:has(input:user-invalid) .has-err { display: block; } .has-field:has(input:user-valid) .has-ok { display: block; } /* Even style the label based on child state */ .has-field:has(input:user-invalid) label { color: red; } .has-field:has(input:user-valid) label { color: green; }

Production registration form — all techniques combined

novalidate + per-error ValidityState messages + :user-invalid styling + :has() for CSS errors + password match + formnovalidate draft button.

8+ characters, one uppercase letter, one number
✓ Account created successfully! (demo)
--:--:--Submit the form to see per-field error messages via ValidityState
Read the tutorial