form state variants

[check: …]
📖 Pair this live Tailwind demo with the companion guide (.md) — this page is the rendered ground truth. ↗ Builds on group_peer (form states are the canonical pairing for peer-checked: / peer-invalid: — the same state, broadcast sideways to a sibling).

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.

variantCSS pseudo-classmatches when…
required::requiredelement has the required attribute
valid::validelement passes HTML5 constraint validation
invalid::invalidelement fails validation (bad email, pattern mismatch, required+empty)
checked::checkedcheckbox or radio is checked
disabled::disabledhas disabled attribute (auto-dims, no pointer events)
enabled::enablednot disabled (the inverse of disabled:)
readonly: / read-only::read-onlyhas readonly attribute (value cannot be changed)
placeholder-shown::placeholder-showninput is empty and currently showing its placeholder
autofill::-webkit-autofillbrowser has auto-filled the value from a saved profile
default::defaultcheckbox/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).

state: —

button.disabled: —

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.

input:-webkit-autofill { /* background-color does NOT work here — the browser paints on top. Use a giant inset box-shadow to "cover" the autofill bg. */ -webkit-box-shadow: 0 0 0 1000px #0b0f14 inset; -webkit-text-fill-color: #e6edf3; /* overrides autofill text color */ caret-color: #e6edf3; /* keeps the cursor visible */ }

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:

class=" autofill:[-webkit-box-shadow:0_0_0_1000px_#0b0f14_inset] autofill:[-webkit-text-fill-color:#e6edf3] autofill:caret-cyan-400 "

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 / typeconstraintexample that fails (becomes :invalid)
requiredfield must have a non-empty valueempty 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 / maxnumeric/date rangemin="0" with value -5
minlength / maxlengthcharacter count rangeminlength="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 minstep="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

intentpatternwhy
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