Most developers reach for JavaScript the moment a form needs validation — a keyup listener here, a regex check there, a red border toggled in code. But the browser has been able to validate forms on its own since HTML5 shipped required, pattern, typed inputs, and a full set of CSS pseudo-classes that expose the result. No listener, no library, no risk of the validation running before the script has finished loading. The browser blocks the submit itself, focuses the offending field, and shows its own message — and CSS alone can style every step of that.
The 10 demos below are all real forms — type into them, try submitting them empty or malformed, and watch the browser's native validation UI do the work. Every embed also has a Fork & Edit button that opens the exact snippet, already running, in My Code — FWD Tools' free in-browser editor. Loosen a pattern, delete a required, and watch what the browser stops catching — that's a faster way to understand constraint validation than any explanation.
If forms are still a shaky part of your HTML in general — not just validation — the HTML Playground is worth having open alongside this post. It's a free, click-based, in-browser course that runs beginner to pro across 42 lessons in 13 chapters, with a dedicated Forms chapter covering input types, form structure, fieldset and legend, and native form validation itself — the same ground this post covers, but as a guided curriculum rather than a single deep dive.
Why this works without a single script tag
Every form field participates in what the spec calls the Constraint Validation API — a built-in, browser-native system that runs automatically the moment a form is submitted. Attributes like required, pattern, minlength/maxlength, and min/max/step each add one constraint. If any field fails its constraint, the browser cancels the submit outright, scrolls to and focuses the first invalid field, and pops up its own message bubble — all before your code, if you had any, would even run. CSS gets a live read on the result too: :valid, :invalid, :required, :optional, and :in-range/:out-of-range all reflect a field's current state, updating the instant the user types.
1. Required Fields
Try clicking "Create account" without filling every field below — the browser blocks the submit and points at the first empty one.
The rule:
<input type="text" name="name" required>
That single attribute is the whole feature. A field with required is invalid whenever it's empty, and the form's native submit handler refuses to fire until every required field holds a value. The styling in this demo — a red border for empty-and-invalid, green for filled-and-valid — comes from CSS alone: input:required:not(:placeholder-shown):invalid only matches once the field is no longer showing its placeholder, so a blank required field doesn't look broken the instant the page loads.
Best for: any field the form genuinely cannot proceed without — name, email, a chosen plan. Tip: pair required with the :not(:placeholder-shown) trick above; without it, every required field renders red before the visitor has typed anything, which reads as a bug rather than a validation state.
2. Native Input Types
Each field below is a different type value. Try typing "not-an-email" in the email field, or letters in the phone field, then submit.
The rule:
<input type="email">
<input type="url">
<input type="tel" pattern="[0-9\-\+\s]{7,}">
<input type="number" min="0" max="120">
Changing type="text" to type="email" or type="url" does two things at once: it swaps in the right on-screen keyboard on mobile, and it makes the browser check the value against the correct format before allowing submission — no regex required on your end for the basics. type="tel" is the odd one out: because phone number formats vary wildly by country, the browser enforces nothing on its own, which is exactly why this demo adds a pattern alongside it.
Best for: the most common form fields on the web — email, URL, phone, and numeric inputs. Tip: always add a pattern to type="tel" if you need real format enforcement; the type alone only changes the keyboard, not the validation.
3. Pattern Matching
The username field only accepts 3–16 letters, numbers or underscores — try typing a space or a symbol, then submit.
The rule:
<input
pattern="[A-Za-z0-9_]{3,16}"
title="3–16 characters: letters, numbers and underscores only">
pattern takes a regular expression — implicitly anchored at both ends, so it must match the entire value, not just a substring — and the browser refuses to submit until the field matches it. This is the single most flexible constraint HTML offers, since almost any format rule (coupon codes, product SKUs, postal codes) can be expressed as one regex without a line of script.
Best for: any format the built-in input types don't already cover — usernames, coupon codes, custom ID formats. Tip: always pair pattern with a title; without one, the browser's default message is a generic "match the requested format," which tells the visitor nothing about what format is actually expected.
4. Number Ranges
Try nudging the quantity above 10 or below 1 with the spinner arrows — it won't let you.
The rule:
<input type="number" min="1" max="10" step="1">
<input type="range" min="0" max="100" step="5">
min, max, and step constrain both number and range inputs identically — the spinner arrows and the slider's draggable positions all respect them automatically. The CSS pseudo-classes :in-range and :out-of-range only apply to fields that actually have a min and/or max set, and only once the field holds a value, so you get free styling for "is this number where it should be" with zero script.
Best for: quantities, ages, percentages, ratings — anything numeric with a sensible floor and ceiling. Tip: a mismatched step (e.g. step="5" starting from a min that isn't a multiple of 5) can make otherwise-reasonable values register as invalid; keep your min aligned to your step.
5. Length Limits
The bio field physically stops accepting keystrokes at 140 characters. The password field lets you type anything, but won't let the form submit under 8.
The rule:
<input type="password" required minlength="8">
<textarea maxlength="140"></textarea>
maxlength is a hard ceiling enforced at the keystroke level — the field literally cannot hold more characters, no submit attempt required to catch it. minlength works the opposite way: it doesn't stop you from typing fewer characters, but it does block the form from submitting until the value reaches that length, since there's no meaningful way to prevent someone from typing "too little."
Best for: passwords (a floor), bios and comment fields (a ceiling), usernames (often both at once). Tip: maxlength alone gives no visual feedback about how many characters remain — a live counter needs script, but the hard limit itself never does.
6. Custom Error Messages
Type an invalid postal code and click submit — the browser's own validation bubble shows the exact wording from the title attribute, not a generic message.
The rule:
<input
pattern="[A-Z]{2}[0-9]{4}"
title="Enter a postal code as 2 letters followed by 4 digits, e.g. AB1234">
Without a title, a failed pattern match shows a browser-generic "please match the requested format" — technically correct, practically useless. Setting title to plain-language instructions replaces that generic text with something the visitor can actually act on, and it costs nothing but the attribute itself.
Best for: any field using pattern, since the default message never explains what the pattern actually requires. Tip: write the title as an instruction ("Enter a postal code as...") rather than a description of the regex — nobody wants to read their own field's regex back at them mid-submit.
7. Styling with :valid and :invalid
Type into each field and watch the border color and the check/× icon react instantly — that's pure CSS reading the browser's own validity state, live, as you type.
The rule:
input:not(:placeholder-shown):valid { border-color: green; }
input:not(:placeholder-shown):invalid { border-color: red; }
input:not(:placeholder-shown):valid ~ .iconOk { opacity: 1; }
This is the demo that makes constraint validation feel like a real feature rather than a fallback: :valid and :invalid update continuously as the user types, not just on submit, and any CSS — a border color, a background tint, an icon shown via a sibling combinator — can key off either state. No input event listener, no manual class toggling; the browser already knows the answer, CSS is just asking it.
Best for: the general pattern behind every other demo on this page — if you take one thing from this post, it's this :not(:placeholder-shown) combo. Tip: the check/× icons here are two separate elements whose opacity toggles, not one icon whose content changes — content-swapping an icon needs script or a content property trick; toggling opacity on two pre-placed icons doesn't.
8. Labeling with :required and :optional
Nothing in this form's markup spells out "required" or "optional" in text — the red asterisk and the "(optional)" tag are both generated purely from each field's required attribute, via CSS.
The rule:
.field:has(input:required) .fieldLabel::after { content: " *"; }
.field:has(input:optional) .fieldLabel::after { content: " (optional)"; }
:required and :optional are simply the inverse of each other — every form field matches exactly one of the two, based purely on whether it carries the required attribute. Combined with :has(), a label can react to its associated input's required-ness without either element needing an extra class. Change required on the input and the label's marker updates itself — there's nothing else to keep in sync.
Best for: generating consistent required/optional markers across a large form without hand-writing an asterisk (or forgetting one) on every label. Tip: :has() is what makes this reach "up" from the input to style its ancestor label — without it, you'd need the asterisk baked directly into each label's HTML instead of derived from the input's state.
9. Required Radio Groups
Click submit without picking a plan — the browser refuses and focuses the group, even though no single radio input is individually marked invalid on its own.
The rule:
<fieldset>
<legend>Choose a plan</legend>
<input type="radio" name="plan" value="free" required>
<input type="radio" name="plan" value="pro" required>
<input type="radio" name="plan" value="team" required>
</fieldset>
Radio buttons are a special case: put required on every input sharing the same name, and the browser treats the whole group as invalid until one of them is checked — it doesn't demand that every individual radio be checked, which would be impossible. <fieldset> and <legend> aren't required for the validation to work, but they give the group a proper accessible name, which matters more here than on a single input since screen readers need to announce what the group of options actually represents.
Best for: plan selectors, shipping method choices, single-answer survey questions — any "pick exactly one" control. Tip: the same pattern doesn't apply to checkboxes the same way; a required checkbox validates individually (commonly used for a single "I agree to the terms" box), not as a group demanding at least one checked.
10. Gating Submit with :has()
Fill in both fields correctly and watch the button light up on its own — form:has(:invalid) reads the whole form's validity state and styles the button from outside it, no script watching either field.
The rule:
form:has(input:invalid) button[type="submit"] {
opacity: .45;
}
:has() lets a selector match a parent based on what's inside it — here, "does this form currently contain any invalid input" — and apply styling to a completely different element, the submit button, based on that answer. It's worth being precise about what this does and doesn't do: the button isn't actually disabled by this CSS, and it doesn't need to be, because the browser's constraint validation already blocks the submit on its own the instant it's clicked while any field is invalid. The dimmed opacity is purely a visual cue that arrives before the click, not the mechanism doing the blocking.
Best for: giving users an early visual signal that a form isn't ready, layered on top of — not replacing — the native validation that was already going to stop an invalid submit regardless. Tip: if you want the button functionally disabled too (removed from the tab order, for instance), that still requires the disabled attribute set via script; :has() alone only ever changes appearance.
Common pitfalls
- Styling a required field red before the user has touched it. An empty required field is
:invalidby definition, from the moment the page loads — if your CSS is justinput:invalid { border-color: red }, every required field on the page starts out looking broken. Add:not(:placeholder-shown)(as every demo above does) so the red state only appears once there's actually something to be wrong about. novalidateon the<form>turns all of this off at once. It's sometimes added deliberately (to let a JS validation layer fully take over), but if native validation mysteriously "stops working," check the form tag itself for a straynovalidatebefore debugging anything else.- A
patternis implicitly anchored, but the value inside it might not need to be.pattern="[0-9]+"requires the entire value to be digits, not just contain some — there's no need to write^[0-9]+$yourself, and doing so is harmless but redundant. - Client-side validation, native or not, is a UX layer — not a security boundary. There's no JavaScript here to disable, but that doesn't make these constraints unbypassable: anyone submitting the form with a tool that skips the browser entirely — curl, a crafted request — skips every constraint in this post along with it. Real validation and sanitization still has to happen again on the server.
- Radio groups validate as a group; checkboxes don't. Don't expect
requiredon three separate checkboxes to mean "check at least one of these three" — each required checkbox is independently mandatory, which is a different rule than demo #9's radio group.
Frequently asked questions
Can you really validate a form with no JavaScript at all?
Yes, for everything covered in this post — presence (required), format (type and pattern), length (minlength/maxlength), and numeric range (min/max/step) are all enforced natively by the browser on submit, with live CSS styling available through :valid/:invalid and related pseudo-classes. JavaScript is only needed for validation logic the browser has no built-in concept of — cross-field rules like "confirm password must match password," or anything that has to check against a server.
How do I stop a required field from looking invalid before the user has typed anything?
Combine the state you care about with :not(:placeholder-shown), as every demo on this page does — input:required:not(:placeholder-shown):invalid only matches once the field no longer holds its placeholder text, meaning the visitor has interacted with it. A field's raw :invalid state alone is true from page load for any empty required field, which is the trap that makes forms look broken by default.
What's the difference between :invalid and :out-of-range?
:invalid is the broad state — it matches a field failing any constraint (required, pattern, type format, length, or range). :out-of-range is narrower: it only applies to fields with a min and/or max, and only matches when the value falls outside that specific numeric boundary. Every :out-of-range field is also :invalid, but not every :invalid field is :out-of-range — a required text field left empty is invalid without being in or out of any range at all.
Can I customize the browser's native validation bubble's text?
Partially. The title attribute (demo #6) replaces the message shown for a failed pattern match with your own wording. For full control over the message's text for any constraint — not just pattern — or over the bubble's appearance, you need the Constraint Validation API's setCustomValidity() method, which does require a small amount of JavaScript.
Does :has() work in every browser?
Yes, as of late 2023 it shipped in every evergreen browser — Chrome, Edge, Safari, and Firefox. If a project needs to support older or less common browsers, check the exact version cutoff on caniuse.com/css-has before relying on it for anything beyond a progressive-enhancement nicety like demo #10's dimmed button.
Is native validation enough, or do I still need server-side validation?
You still need server-side validation, always. Every technique in this post runs entirely in the browser and can be bypassed by anyone submitting the form outside a browser UI — a script, curl, a crafted request. Native validation is a genuinely good UX layer that stops the vast majority of accidental mistakes before they ever leave the browser, but it is not, and was never meant to be, a security boundary.
Every demo above ships its full HTML and CSS in the HTML / CSS / JS tabs in its own top bar — and the JS tab is empty on every single one, on purpose. The fastest way to actually internalize these is to click Fork & Edit on demo #7, delete the required attribute from the email field, and watch the check/× icons stop appearing entirely — a non-required empty field is always valid, so there's no invalid state left for the CSS to key off. Once you've got a version you like, save it to My Code and it's yours to keep, tweak further, or reuse in a real project.
Recommended next step: open the HTML Playground and go to the Forms chapter — it covers input types, form structure, fieldset and legend, and native form validation as a dedicated, click-based lesson set, built for exactly the pattern this post covers. For more tutorials like this one, browse the HTML label on the blog.
