Style Form Fields After Interaction with Tailwind v4's user-valid and user-invalid

The Tailwind user-invalid variant styles form fields only after the user interacts, so required inputs stop flashing red on load. No JavaScript required.

Steven Richardson
Steven Richardson
· 6 min read

Load a signup form with a required email field and watch it light up red before anyone has typed a single character. That is :invalid doing its job too eagerly — an empty required field is invalid the instant the page renders. The fix is not a pile of JavaScript toggling classes on blur. It is the :user-invalid pseudo-class, and the Tailwind user-invalid variant, shipped since Tailwind v4.1, exposes it directly.

Why :invalid fires red too early#

:valid and :invalid reflect an element's constraint validity at every moment, including first paint. A required field with no value is invalid immediately, so the naive Tailwind approach paints an angry red border on a form the user has not even touched:

<!-- Red on page load, before the user does anything -->
<input
  type="email"
  required
  class="rounded-md border border-gray-300 px-3 py-2 invalid:border-red-500"
/>

That is technically correct and terrible UX. Nobody wants a wall of red the moment a page loads. The usual workaround is JavaScript: track a "touched" state per field, toggle a class on blur, wire it all up per input. It works, and it is a lot of code to reimplement something the browser already does.

The user-invalid and user-valid variants#

The browser exposes two pseudo-classes that solve exactly this: :user-invalid and :user-valid. They behave like :invalid/:valid but only match after the user has interacted with the field — typed and blurred, or tried to submit. Before that, neither matches, so nothing is styled.

Tailwind v4.1 ships them as first-class variants. Swap invalid: for user-invalid: and add the positive case:

<input
  type="email"
  required
  class="rounded-md border border-gray-300 px-3 py-2
         user-valid:border-green-500
         user-invalid:border-red-500"
/>

Now the border stays neutral grey until the user engages with the field. Enter a valid address and it turns green; leave it broken and it turns red. No touched flag, no event listeners. This is the same variant-composition engine behind another Tailwind v4 variant, not-* — a thin utility wrapper over a CSS pseudo-class the platform already supports.

Wiring the user-invalid variant to HTML constraints#

The variants are only as smart as the constraints you give the browser. :user-invalid is driven by the Constraint Validation API, so it needs real HTML validation attributes to have an opinion. A bare <input> with no constraints will never be user-invalid, because there is nothing for it to be invalid against.

Give the browser rules and it does the tracking for you:

<input
  type="email"
  required
  minlength="6"
  pattern="[^@\s]+@[^@\s]+\.[^@\s]+"
  class="rounded-md border border-gray-300 px-3 py-2
         user-valid:border-green-500
         user-invalid:border-red-500"
/>

required makes an empty field invalid, type="email" enforces email syntax, minlength sets a floor, and pattern adds a regex on top. The browser evaluates all of them and flips :user-valid / :user-invalid accordingly — this is tailwind required field styling with zero custom JavaScript. If you like this no-JavaScript instinct, it is the same one behind auto-growing textareas with field-sizing.

Styling labels and messages with peer and has-*#

Styling the input is half the job. You usually want to reveal an error message and maybe recolour the whole field group too.

For a sibling error message, mark the input as a peer and show the message with peer-user-invalid:. Because peer-* targets later siblings, the message has to sit after the input in the DOM:

<div>
  <input
    id="email"
    type="email"
    required
    class="peer rounded-md border border-gray-300 px-3 py-2
           user-invalid:border-red-500 user-valid:border-green-500"
  />
  <p class="mt-1 hidden text-sm text-red-600 peer-user-invalid:block">
    Enter a valid email address.
  </p>
</div>

The message is hidden by default and only becomes block once the peer input is :user-invalid — that is, after the user has interacted and left it broken.

To style a whole wrapper — border, background, the lot — reach for has-[:user-invalid]:, which matches when any descendant is user-invalid. This is handy when the label comes before the input and peer-* cannot reach it:

<div class="rounded-lg border border-gray-200 p-4
            has-[:user-invalid]:border-red-500
            has-[:user-invalid]:bg-red-50">
  <label for="email" class="block text-sm font-medium">Email</label>
  <input
    id="email"
    type="email"
    required
    class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2
           user-invalid:border-red-500"
  />
</div>

Gotchas and Edge Cases#

A few things bite people in production:

No constraints, no styling. :user-invalid needs something to validate against. A plain text input with no required, type, pattern, or minlength will never match, so the variant appears to "do nothing". Add a constraint.

The browser decides when "interacted" means. Chrome, Firefox, and Safari agree that the pseudo-class must not match before interaction, but they differ slightly on the exact trigger — blur with bad input, or a submit attempt. Do not build logic that assumes a precise moment; treat it as "sometime after the user engages".

peer only reaches later siblings. peer-user-invalid: styles elements that come after the peer input. If your markup puts the label or message before the input, use a has-[:user-invalid]: wrapper instead.

Validity is syntax, not business rules. The browser knows whether an email is shaped like an email. It has no idea whether that address is already taken. For real server rules you still need backend validation — see live server-side validation with Laravel Precognition.

You need Tailwind v4.1+. The first-class user-invalid: and user-valid: variants landed in v4.1. On an older setup you can still use the arbitrary form [&:user-invalid]:border-red-500, or run the official v3-to-v4 upgrade tool. The underlying pseudo-classes are Baseline across evergreen browsers, and v4.1 degrades gracefully on older ones.

Wrapping Up#

Reach for user-invalid: and user-valid: whenever you want inline validation feedback without shipping a line of JavaScript. Add real HTML constraints, style the input with the variants, and use peer-user-invalid: or has-[:user-invalid]: for the surrounding message and group.

When you outgrow syntax checks and need to validate against your actual backend rules — uniqueness, business logic, anything the browser cannot know — pair this styling with Laravel Precognition for live server validation or move your rules into Livewire 4 form objects with #[Validate].

FAQ#

What's the difference between :invalid and :user-invalid?

Both reflect whether a field passes its HTML constraints, but they differ on timing. :invalid matches the moment the element is invalid, including on page load, so an empty required field is flagged before the user does anything. :user-invalid only matches after the user has interacted with the field — typed and moved on, or attempted a submit — which is almost always the behaviour you actually want.

How do I style invalid inputs in Tailwind CSS v4?

Use the user-invalid: variant on the input, for example user-invalid:border-red-500, and add the positive user-valid:border-green-500 if you want a success state. These first-class variants shipped in Tailwind v4.1. The field stays neutral until the user interacts, then turns red or green based on the browser's constraint validation.

Do I need JavaScript to show form validation styles?

No. The :user-valid and :user-invalid pseudo-classes are driven entirely by the browser's Constraint Validation API, so the styling happens natively once you add HTML constraints like required or type="email". You only need JavaScript if you want custom messages beyond the browser defaults or validation against server-side rules the browser cannot check.

Why does my required field show red before the user types anything?

You are almost certainly using :invalid (or Tailwind's invalid: variant), which matches immediately — an empty required field is invalid from first paint. Switch to user-invalid:, which waits until the user has interacted before matching, and the premature red border disappears.

Can I style a label or error message based on input validity in Tailwind?

Yes. For a message that follows the input, mark the input peer and reveal the message with peer-user-invalid:block. To restyle a whole wrapper — including a label that sits before the input — put has-[:user-invalid]: utilities on the container, which match when any descendant field is user-invalid.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.