Form UX in 2026 Native HTML Validation Is Finally Enough
- Removed a 12 KB validation library across 3 surfaces using native HTML
- :user-valid and :user-invalid fire only after interaction, killing premature error noise
- accent-color styles checkboxes and radios in one line, no SVG hacks
- Form-associated custom elements let web components join native validation
I deleted a 12 KB form validation library last month and the forms got better, not worse. Three RAXXO surfaces now run on native HTML validation alone, and the error timing feels more polished than the JavaScript version it replaced. Here are the 5 patterns that made it possible.
The Constraint Validation API Does The Heavy Lifting
Every input element already carries a validity object. You do not import it. You do not configure it. It is sitting on `input.validity` right now, tracking eight separate states: `valueMissing`, `typeMismatch`, `patternMismatch`, `tooLong`, `tooShort`, `rangeUnderflow`, `rangeOverflow`, and `stepMismatch`. The browser computes these on every keystroke for free.
My old setup had a rule engine. Required fields, email format, minimum length, all of it lived in a config object that I hand-fed to the library on page load. That config was 140 lines across the checkout form, the contact form, and the newsletter signup. I replaced almost all of it with attributes the browser has understood for over a decade.
The `required` attribute handles `valueMissing`. The `type="email"` handles `typeMismatch`. The `pattern` handles `patternMismatch` with a regex. No library parses these. The HTML parser does.
When I do need custom logic, like checking that a discount code matches a format the regex cannot express, I call `setCustomValidity()`. Pass it a string and the field becomes invalid with that message. Pass it an empty string and the field becomes valid again. That is the entire API surface for custom rules.
input.addEventListener('input', () => {
const ok = /^RAXXO-\d{4}$/.test(input.value);
input.setCustomValidity(ok || !input.value ? '' : 'Code format is RAXXO-1234');
});
The form's own `checkValidity()` method walks every field and returns true or false. The `reportValidity()` method does the same but also shows the native error bubble on the first bad field. Two method calls replaced a submit handler that was 60 lines long. If you want the full reasoning behind why I keep cutting dependencies like this, see Why I Delete Libraries First. The pattern repeats across every surface I touch.
:user-valid And :user-invalid Fixed My Worst UX Problem
The classic complaint about native validation is timing. The plain `:invalid` pseudo-class matches an empty required field the instant the page loads. So every form lights up red before the visitor has typed a single character. That is hostile, and it is why most people reached for a library in the first place. The library tracked a "touched" state per field so errors only appeared after you interacted.
The browser does that now. `:user-invalid` only matches after the user has interacted with the field and then left it in a bad state. `:user-valid` is the mirror. No touched state to track. No blur listeners. The browser owns the interaction history.
input:user-invalid {
border-color: #d4183d;
}
input:user-valid {
border-color: #1a7f46;
}
input:user-invalid + .error-text {
display: block;
}
This single change removed the largest chunk of my old library's job. The touched-state machinery was roughly a third of its code. I checked the support tables before committing: `:user-valid` and `:user-invalid` ship in every current browser engine, and the fallback is graceful. Older browsers just do not show the green or red until submit, which is acceptable.
The error text trick above is worth calling out. I put a hidden `.error-text` span after each input and reveal it with the sibling combinator only when the field is in a `:user-invalid` state. The browser decides when. I just decide how it looks. No JavaScript runs to show or hide a single error message anymore.
On the checkout surface this mattered most. Visitors fill in eight fields. Under the old system, a stray re-render could flash all eight red. Under `:user-invalid`, a field stays neutral until you have actually touched it and moved on. The form feels calmer. I did not write a line of code to make it calmer. I deleted the lines that were making it loud. For the broader thinking on shipping less and getting more, The Subtraction Habit covers the mindset that led me here.
accent-color And :has() Killed My Custom Control CSS
Checkboxes and radio buttons used to be the reason people built entire form kits. The native ones were ugly and unstylable, so every library shipped its own SVG-based replacements with hidden inputs and pseudo-elements. That replacement code is a liability. It breaks keyboard focus, it confuses screen readers, and it adds weight.
`accent-color` ended that for me with one line.
:root {
accent-color: #6c3df4;
}
That recolors every checkbox, radio, range slider, and progress bar on the page to match my brand purple. The controls stay native. Keyboard navigation works because I never replaced the real element. Screen readers announce them correctly because they are still real inputs. I deleted maybe 200 lines of SVG checkbox markup and CSS across the three surfaces and replaced it with that one declaration plus a per-form override where I needed a different shade.
Then `:has()` cleaned up the layout logic. I used to toggle a class on the label wrapper when a checkbox was checked, which meant a change listener and a `classList.toggle`. Now the CSS reads the state directly.
label:has(input:checked) {
background: #f3effe;
font-weight: 600;
}
.shipping-options:has(:checked) .next-button {
opacity: 1;
}
The parent reacts to a child's checked state with no JavaScript at all. That second rule enables a button only once a shipping option is selected, purely in CSS. I tested this against the same flows I run for product visuals, where I lean on tools like Magnific for upscaling, and the form felt as crisp as the imagery around it.
The combined effect: the visual customization that justified a form library is now four CSS declarations. `accent-color` for the controls, `:has()` for the reactive states, `:user-invalid` for the errors, and a sibling combinator for the messages. I keep finding that the platform shipped the feature while I was busy importing a workaround for it. If you want the deeper context on building UI without frameworks, I covered this in more depth at Vanilla UI That Scales.
Form-Associated Custom Elements Closed The Last Gap
There was one thing native validation could not do, and it was the reason I kept a sliver of the old library around. I have a custom rating widget, a five-star control built as a web component. It is not a real input. So it could not participate in the form's validity. It could not be `required`. It could not show in `checkValidity()`. It sat outside the system.
Form-associated custom elements fixed that. By setting `static formAssociated = true` and grabbing an `ElementInternals` object, a web component becomes a first-class form participant. It submits a value. It joins constraint validation. It can mark itself invalid with a message and the form will refuse to submit, exactly like a native input.
class StarRating extends HTMLElement {
static formAssociated = true;
#internals;
constructor() {
super();
this.#internals = this.attachInternals();
}
set value(v) {
this.#internals.setFormValue(v);
this.#internals.setValidity(
v ? {} : { valueMissing: true },
'Please pick a rating'
);
}
}
The `setFormValue` call puts the rating into the form data on submit. The `setValidity` call wires it into the same validity machinery every native input uses. Now `form.checkValidity()` includes my custom widget. The `:user-invalid` styling applies to it. The submit gets blocked if no star is chosen. I deleted the bridge code that used to glue my widget into the JavaScript validation library, because the widget now talks to the browser directly.
This was the last 12 KB to go. The library survived as long as it did purely because of this one widget, and once `ElementInternals` covered it, the whole dependency had no reason to exist. I removed the import, ran the three forms through every failure case I could think of, and shipped. Nothing broke.
If you build custom controls and assume they cannot join native validation, check again. The capability is in every current engine. The same principle drives the architecture I document in the Claude Blueprint, where I lay out how I keep my whole stack lean enough for one person to maintain. Less glue code means fewer things that can rot.
Bottom Line
Native HTML validation in 2026 is not a partial solution you grudgingly accept. It is the better one. The Constraint Validation API handles rules. `:user-valid` and `:user-invalid` handle timing, which was the only real reason libraries existed. `accent-color` and `:has()` handle styling without replacing real controls. Form-associated custom elements pull your web components into the same system. Five patterns, zero dependencies, 12 KB lighter across three surfaces.
The forms are faster to load, easier to maintain, and more accessible, because I stopped reinventing controls the browser already ships correctly. I write less code now and the code I keep does more, because the platform absorbed the work.
If you are still importing a validation library, open your form and try deleting it. Wire the attributes back to native, add the four CSS rules, and see what actually breaks. For me, almost nothing did. If you want the full picture of how I keep a solo studio shipping with this little overhead, the Claude Blueprint walks through the whole approach end to end.
This article contains affiliate links. If you sign up through them, I may earn a small commission at no extra cost to you. (Ad)
Back to all articles