CSS
    formsfrontend

    Styling Form States: Beyond :valid and :invalid

    A required email field showing an error state before anyone's typed a single character isn't a bug, it's a spec quirk. Here's the pseudo-class combination that fixes the timing problem.

    Editor: Paul RadfordJun 10, 20267 min read

    Paul RadfordFull-Stack Developer & Editor. Paul is a full-stack developer and editor of template.tips. Articles here are AI-drafted and reviewed by Paul, who knows the code well enough to catch what's wrong and cut the hype.

    Styling Form States: Beyond :valid and :invalid

    :valid and :invalid only tell you whether a value currently satisfies constraint validation, and they fire before a user has even touched a field, which makes them unreliable for real-world UX. Modern form styling relies on a wider set of pseudo-classes, ARIA attributes, and JavaScript-driven data attributes to model timing, interaction, and grouping correctly.

    Why :invalid alone produces bad UX

    The classic complaint: load a form with a required email field, and it's already :invalid before the user types a single character. If you write input:invalid { border-color: red; }, every required field greets the user with an error before they've done anything wrong. This is a spec quirk, not a bug. The HTML Living Standard defines :invalid purely on constraint satisfaction, with no concept of "has the user interacted with this yet."

    The naive fix, wrapping everything in JavaScript, throws away free browser behavior. The better fix is combining native pseudo-classes so validity styling only kicks in after a meaningful interaction.

    css
    1/* Only show invalid styling after the user leaves the field,
    2 or after the form attempts a submit. : user-invalid handles
    3 the common "blur after typing" case in supporting browsers. */
    4input: user-invalid {
    5 border-color: #d92d20;
    6 background-color: #fef3f2;
    7}
    8
    9/* Fallback pattern for browsers without : user-invalid:
    10 pair : invalid with :not(:focus) and a data attribute set on
    11 blur via JS, so untouched fields never show red on first paint. */
    12input[data-touched]: invalid:not(:focus) {
    13 border-color: #d92d20;
    14}
    15
    16input: valid[data-touched] {
    17 border-color: #067647;
    18}

    :user-invalid is defined in the Selectors Level 4 draft and ships in Firefox (since version 88) and Safari 16.4+, but Chrome and Edge still lack it as of early 2026. Check current status on caniuse before relying on it without a fallback.

    What actually changed: :user-valid and :user-invalid

    These two pseudo-classes exist specifically to fix the "invalid on load" problem. :user-invalid matches an element that is currently invalid and that the user has interacted with (typically after a blur or a failed submit attempt). :user-valid is the positive counterpart, useful for showing a green checkmark only after the user has actually entered something that passes validation, rather than on every empty-but-technically-valid optional field.

    The practical gotcha: "interacted with" is left loosely defined by the spec and implemented slightly differently across engines. Firefox flips the state on blur. Safari's behavior has shifted across point releases. Don't assume pixel-identical timing across browsers, test manually rather than trusting a single QA pass.

    How do you style a field only after the user has actually left it?

    If you need consistent behavior across all engines today, including Chrome and Edge, you still need a JavaScript-set attribute rather than relying purely on :user-invalid. This isn't a failure of CSS, it's the honest state of cross-browser support in 2026.

    javascript
    1// Mark a field "touched" on first blur, then let CSS handle
    2// the rest via [data-touched] combined with :valid/:invalid.
    3document.querySelectorAll('input, select, textarea').forEach((field) => {
    4 field.addEventListener('blur', () => {
    5 field.setAttribute('data-touched', '');
    6 }, { once: true }); // only need the first blur to flip the flag
    7});
    8
    9// On submit, force-touch every field so validation errors
    10// surface even if a user never blurred a field (e.g. tabbed
    11// past it, or the form was pre-filled by autofill).
    12form.addEventListener('submit', (event) => {
    13 const invalidFields = form.querySelectorAll(':invalid');
    14 if (invalidFields.length > 0) {
    15 event.preventDefault();
    16 form.querySelectorAll('input, select, textarea')
    17 .forEach((f) => f.setAttribute('data-touched', ''));
    18 invalidFields[0].focus();
    19 }
    20});

    This pattern is boring, which is exactly why it's reliable. It works identically in every browser back to whatever baseline you support, and it doesn't depend on a pseudo-class landing consistently across engines.

    Styling in-progress states: :placeholder-shown and :focus-within

    Two frequently underused selectors solve real layout problems. :placeholder-shown matches an input whose placeholder is currently visible, meaning the field is empty. It's the backbone of floating-label patterns without JavaScript:

    css
    1/* Classic floating label, zero JS. The label sits over the
    2 input until the placeholder disappears (user has typed
    3 something), then it animates up. */
    4.field {
    5 position: relative;
    6}
    7
    8.field label {
    9 position: absolute;
    10 top: 0.75rem;
    11 left: 0.75rem;
    12 transition: transform 0.15s ease, font-size 0.15s ease;
    13 pointer-events: none;
    14}
    15
    16.field input: not(:placeholder-shown) + label,
    17.field input: focus + label {
    18 transform: translateY(-1.25rem);
    19 font-size: 0.75rem;
    20}

    :focus-within matches a container that holds a focused descendant, which is what you want for styling a fieldset or a custom "input group" (icon plus text field plus clear button) as a single unit whenever any part of it has focus. Support is solid across all current engines and has been since roughly 2020, so this one carries no real caveat beyond very old Safari versions nobody should still be targeting.

    Grouping errors: styling :has() for form-level state

    :has() lets a parent react to a child's validity, which was previously impossible without JavaScript. This matters for form-level summaries, disabling a submit button, or highlighting an entire fieldset when any radio in a required group is unchecked.

    css
    1/* Highlight an entire fieldset red if any input inside it
    2 is currently invalid and has been touched. Requires : has()
    3 support: Chrome 105+, Safari 15.4+, Firefox 121+. */
    4fieldset: has(input:invalid[data-touched]) {
    5 border: 2px solid #d92d20;
    6 border-radius: 6px;
    7}
    8
    9/* Disable a submit button declaratively when the form contains
    10 any invalid, touched field, no JS event listener needed for
    11 the visual state (though you still need it to block submission
    12 in older browsers or as a defense-in-depth measure). */
    13button[type="submit"]: has(~ * :invalid[data-touched]) {
    14 opacity: 0.5;
    15 cursor: not-allowed;
    16}

    The button example is fragile because sibling-combinator reach depends on exact DOM structure, and :has() combined with a general sibling selector can get expensive to compute on very large forms since the browser has to re-evaluate the relationship on every keystroke inside the subtree. For anything beyond a handful of fields, toggle a disabled attribute via JavaScript instead and reserve :has() for smaller, more contained cases like the fieldset example. Check current :has() numbers on caniuse if you support anything older than the versions above, since it's a relatively recent addition and older Chrome/Edge (below 105) and older Safari (below 15.4) don't have it at all.

    ARIA states deserve their own styling hooks

    Constraint validation pseudo-classes only cover native HTML validation. The moment you build a custom validation message, an async server-side check (username availability, for example), or a multi-step wizard, you're managing state that :valid/:invalid know nothing about. That's what aria-invalid is for, and it's worth styling directly rather than trying to force native pseudo-classes to cover cases they weren't designed for.

    css
    1/* Style based on aria-invalid, set by JS after an async check
    2 (e.g. "username already taken") rather than native constraints.
    3 This also keeps screen reader announcements in sync with the
    4 visual state, since aria-invalid="true" is what triggers most
    5 AT to announce the field as erroneous. */
    6input[aria-invalid="true"] {
    7 border-color: #d92d20;
    8}
    9
    10input[aria-invalid="false"] {
    11 border-color: #067647;
    12}

    Do not set aria-invalid="true" on every field on page load just to reuse this CSS hook. Screen reader users will hear every field announced as invalid before they've interacted with anything, which is worse than the visual-only problem :invalid has. Set the attribute in JavaScript at the same moment you'd add data-touched, and remove it (or set it to "false") once the check passes. The MDN aria-invalid reference has the accepted value list and screen reader behavior notes if you need to confirm what values are respected.

    Testing this properly

    Don't trust a single manual click-through in Chrome and call it done. Test each state machine explicitly: load the form fresh and confirm nothing shows as invalid, tab through every field without typing and confirm nothing turns red, fill one field wrong and blur it and confirm only that field reacts, then submit with multiple errors and confirm every invalid field lights up at once. Run that same four-step pass in Firefox and Safari separately if you're leaning on :user-invalid, because the interaction-timing differences mentioned earlier are exactly the kind of thing that looks fine in one engine and janky in another. If you're supporting :has()-based fieldset highlighting, profile a large form (30+ fields) for input lag before shipping it, since the selector's recalculation cost scales with DOM complexity in ways that don't show up on a five-field test form.

    Related Articles