Forms are where users do real work, and default browser controls vary widely. This lesson builds consistent form patterns with plain utilities — no plugin required — covering text fields, well-timed validation, selects and checkboxes, inputs with icons, and responsive layout, so any form looks and behaves the same in every browser.
A well-styled input has a visible label, comfortable padding, a subtle border, a focus ring replacing the browser outline, and a disabled look.
<label for="name" class="block text-sm font-medium text-gray-700">Full name</label>
<input id="name" type="text"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-xs
placeholder:text-gray-400 focus:border-blue-500 focus:outline-hidden
focus:ring-2 focus:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50" />focus:outline-hidden removes the default outline (keeping one in forced-colors mode) and the ring replaces it. Connect the label with for/id so clicks focus the input and screen readers announce it, and keep this class string in one place — a component or @utility — so every input shares it.
invalid: matches as soon as the page loads, painting every empty required field red before the user types. Two better options exist:
<!-- Native validation, shown only after the user has interacted (v4.1+) -->
<input type="email" required class="… user-invalid:border-red-500 user-invalid:ring-red-500/30" />
<!-- Server-side validation, driven by an attribute -->
<input type="text" aria-invalid="true" aria-describedby="username-error"
class="… aria-[invalid=true]:border-red-500" />
<p id="username-error" class="mt-1 text-sm text-red-600">That username is taken.</p>user-invalid: maps to :user-invalid, which matches only once the field has been touched. For server-side errors, set aria-invalid="true" and point aria-describedby at the message so assistive technology reads it. A peer on the input lets the message reveal itself with peer-user-invalid:block, as in the sample code.
Selects and textareas take the same base classes. Checkboxes and radios are best left native and colored with accent-*, which sets the checked color without a reset:
<select class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:ring-2 focus:ring-blue-500/30">
<option>Monthly</option>
</select>
<textarea rows="4" class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 field-sizing-content"></textarea>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" class="size-4 accent-blue-600" /> Remember me
</label>field-sizing-content (v4.1+) lets a textarea grow with its content. For fully custom checkbox visuals use the peer technique or the forms plugin.
An icon inside an input is an absolutely positioned element over a padded input:
<div class="relative">
<svg class="pointer-events-none absolute inset-y-0 left-3 my-auto size-5 text-gray-400">...</svg>
<input type="search" placeholder="Search" class="… ps-10" />
</div>pointer-events-none lets clicks pass through the icon, inset-y-0 my-auto centres it, and ps-10 reserves room for it. A text addon such as https:// is a flex row where the addon span gets rounded-s-md border-e-0 and the input rounded-e-md; logical corners keep it correct in right-to-left layouts.
Group fields with <form class="grid gap-4 sm:grid-cols-2">: they stack on phones and pair up from sm, full-width rows take sm:col-span-2, and the button row is flex justify-end gap-3 sm:col-span-2 with the primary action last. Use <fieldset> and <legend> for radio and checkbox groups.
invalid: alone, which shows errors before the user has typed.outline-none and providing no focus ring.Which variant shows a validation style only after the user has interacted with the field?
block w-full rounded-md border border-gray-300 px-3 py-2 plus focus:outline-hidden focus:ring-2; reuse it everywhere.user-invalid: for native validation and aria-[invalid=true]: with aria-describedby for server errors.accent-*; let textareas grow with field-sizing-content.pointer-events-none over a ps-10 input.grid gap-4 sm:grid-cols-2 and sm:col-span-2 for full-width rows.Next lesson: Tables, Lists and Data Display — present rows of data with striped tables, sticky headers and well-spaced lists.