Forms with Real Validation States
Style focus, required, invalid and disabled states — with a floating label, no JS.
Good forms communicate. Tailwind's state variants let the input itself tell the user what is happening, without a line of JavaScript.
The states worth styling
focus:— a clear ring:focus:ring-2 focus:ring-cyan-300 focus:border-cyan-500.required+invalid:/user-invalid:— a red border once the field is wrong.disabled:— dimmed andcursor-not-allowed.
Floating labels with peer
Place the input first with a placeholder, mark it peer, then position the label and animate it on peer-focus and peer-[:not(:placeholder-shown)]:
<input placeholder=" " class="peer…">
<label class="peer-focus:-translate-y-4 peer-focus:text-cyan-600…">Email</label>Show errors at the right time
Plain invalid: fires the instant the field is empty, which nags. Prefer user-invalid: (styles only after the user has interacted), or gate a peer error message on it, so validation feels calm rather than accusatory.
Example
When to use it
- A login form highlights the email field with a cyan ring on focus and turns the border red via the invalid: variant when the user submits an empty value, with no JavaScript required.
- A checkout form disables and grays out the billing address fields when a "Same as shipping" checkbox is toggled, using the disabled: variant to communicate unavailability.
- A sign-up form uses a floating label pattern — the label slides up and shrinks when the input has a value — implemented with the peer utility and CSS transitions, zero JS.
More examples
Input with focus and invalid states
Uses focus: and invalid: variants so the border and ring change color automatically based on browser-native validation state — no JS event listeners needed.
<div class="flex flex-col gap-1">
<label for="email" class="text-sm font-medium text-gray-700">Email</label>
<input
id="email" type="email" required placeholder="[email protected]"
class="rounded-lg border border-gray-300 px-3 py-2 text-sm
focus:outline-none focus:ring-2 focus:ring-cyan-300 focus:border-cyan-500
invalid:border-red-400 invalid:ring-red-200
placeholder:text-gray-400"
/>
</div>Disabled field with visual cue
disabled:opacity-60 and cursor-not-allowed communicate non-interactivity without custom CSS, matching native browser disabled behaviour visually.
<fieldset class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label for="name" class="text-sm font-medium text-gray-700">Full name</label>
<input id="name" type="text"
class="rounded-lg border border-gray-300 px-3 py-2 text-sm
focus:outline-none focus:ring-2 focus:ring-cyan-300 focus:border-cyan-500" />
</div>
<div class="flex flex-col gap-1">
<label for="promo" class="text-sm font-medium text-gray-400">Promo code</label>
<input id="promo" type="text" disabled placeholder="N/A for your plan"
class="rounded-lg border border-gray-200 px-3 py-2 text-sm bg-gray-50
text-gray-400 cursor-not-allowed disabled:opacity-60" />
</div>
</fieldset>Floating label with peer utility
The peer class on the input exposes its state to the sibling label; peer-placeholder-shown and peer-focus move and shrink the label with a CSS transition — pure HTML/Tailwind, no JS.
<div class="relative mt-6">
<input
id="fname" type="text" placeholder=" "
class="peer w-full rounded-lg border border-gray-300 px-3 pt-5 pb-2 text-sm
focus:outline-none focus:ring-2 focus:ring-cyan-300 focus:border-cyan-500"
/>
<label for="fname"
class="absolute left-3 top-3 text-gray-400 text-sm transition-all duration-200
peer-placeholder-shown:top-3 peer-placeholder-shown:text-sm
peer-focus:top-1 peer-focus:text-xs peer-focus:text-cyan-600">
First name
</label>
</div>
Discussion