Form state variants — style inputs from their own pseudo-class
Every HTML form control carries state the browser already tracks:
required, valid/invalid (via HTML5 constraint validation), checked,
disabled, read-only, placeholder-shown, autofill, default. Tailwind v4
exposes each as a variant suffix that maps to the corresponding CSS
pseudo-class. You style the state declaratively — no JS class
toggling, no React useState, no Alpine store.
| variant | CSS pseudo-class | matches when… |
|---|---|---|
required: | :required | element has the required attribute |
valid: | :valid | element passes HTML5 constraint validation |
invalid: | :invalid | element fails validation (bad email, pattern mismatch, required+empty) |
checked: | :checked | checkbox or radio is checked |
disabled: | :disabled | has disabled attribute (auto-dims, no pointer events) |
enabled: | :enabled | not disabled (the inverse of disabled:) |
readonly: / read-only: | :read-only | has readonly attribute (value cannot be changed) |
placeholder-shown: | :placeholder-shown | input is empty and currently showing its placeholder |
autofill: | :-webkit-autofill | browser has auto-filled the value from a saved profile |
default: | :default | checkbox/radio/option has the checked/selected attribute in source HTML |
Two newer pseudo-classes also exist — :user-invalid /
:user-valid — which only apply after the user has
interacted with the field (so an untouched required field is not
flagged red on first paint). Tailwind v4 exposes them as
user-invalid: / user-valid:. They are the
accessible choice over bare invalid:.
1 · live form validation demo — invalid: / valid: / checked:
Type in the email field. The border turns red
while invalid (empty + required, or a malformed email) and
green once a valid email is typed. Toggle
the consent checkbox — the submit button only enables when the box is
checked. Zero JavaScript drives the visual state — only the
disabled toggle on the button uses JS, and that could also
be done with peer-checked: (see
group_peer).
Class breakdown — invalid:border-red-500 valid:border-green-500
swap border color based on the live HTML5 validity state.
peer-checked:bg-cyan-500 on the button reads the checkbox
(which carries class="peer") — that is the
group_peer
mechanism, layered on top of the form state. The
enabled: variant is the inverse of disabled: —
it matches only when the button is interactable.
2 · autofill: — overriding the browser's autofill chrome
When Chrome/Edge/Safari autofill an input, they paint a hardcoded
background color (yellow in Chrome, blue in Safari) and a hardcoded
text color — and plain background-color cannot override
it. The only escape hatch is the non-standard
:-webkit-autofill pseudo-class, which Tailwind v4 exposes
as autofill:. You pair it with the
box-shadow-inset hack to paint the field your way.
In Tailwind, you can apply this with arbitrary-value utilities under the
autofill: variant. Browsers block programmatic autofill
(you cannot trigger :-webkit-autofill from JS), so the
mock below shows the visual before/after using static classes:
default browser autofill (simulated)
with autofill: override (simulated)
The exact Tailwind classes to apply to a live input:
3 · placeholder-shown: — different style when empty
:placeholder-shown matches an input that is currently
displaying its placeholder — i.e. it is empty. This lets you
style the empty-vs-filled distinction without JS. Below: a dashed
border when empty, a solid cyan border once the user types anything.
:placeholder-shown: —
placeholder-shown: is NOT the same as the
::placeholder pseudo-element (which Tailwind exposes as
placeholder:). The former targets the input element
itself when empty; the latter targets only the placeholder
text. They are easy to confuse.
HTML5 validation rules — what triggers :invalid
:invalid / :valid are powered by HTML5
constraint validation — the browser runs these checks on every
input event. No JavaScript checkValidity() call needed.
| attribute / type | constraint | example that fails (becomes :invalid) |
|---|---|---|
required | field must have a non-empty value | empty text input with required |
type="email" | must match the email regex (has @ + domain) | "hello", "a@b" (no TLD) |
type="url" | must be a valid absolute URL | "example.com" (missing scheme) |
type="number" | must be a parseable number | "abc" |
min / max | numeric/date range | min="0" with value -5 |
minlength / maxlength | character count range | minlength="8" with value "abc" |
pattern="…" | must match the regex (anchored) | pattern="[0-9]{4}" with value "12" |
step="n" | value must be a multiple of n from min | step="2" with value 3 |
Gotcha: an empty input WITHOUT required is always
:valid (no constraint to violate). This is why the email
demo above uses required — otherwise the empty field would
already be green.
intent → pattern
| intent | pattern | why |
|---|---|---|
| red border on bad email, green on good | invalid:border-red-500 valid:border-green-500 |
reads :invalid / :valid — no JS |
| only show error AFTER user interacts | user-invalid:border-red-500 |
:user-invalid skips untouched required fields — better UX |
| mark required fields visually | required:ring-1 required:ring-cyan-500 |
one class lights up every field with the required attr |
| dim disabled inputs further | disabled:opacity-50 disabled:cursor-not-allowed |
built on the native :disabled pseudo-class |
| style empty vs filled differently | placeholder-shown:border-dashed |
fires only while the placeholder is visible |
| style a read-only field as static | read-only:bg-slate-800 read-only:text-slate-400 |
alias readonly: also works |
| override browser autofill colors | autofill:[-webkit-box-shadow:0_0_0_1000px_#000_inset] |
only :-webkit-autofill + box-shadow hack works |
| style the default radio in a group | default:border-cyan-500 |
matches the option that had checked in source HTML |