Styling Forms and Inputs

Intermediate
12 min

Styling Forms and Inputs

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.

The text field pattern

A well-styled input has a visible label, comfortable padding, a subtle border, a focus ring replacing the browser outline, and a disabled look.

html
<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.

Validation that appears at the right time

invalid: matches as soon as the page loads, painting every empty required field red before the user types. Two better options exist:

html
<!-- 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, textareas, checkboxes and radios

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:

html
<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.

Inputs with icons and addons

An icon inside an input is an absolutely positioned element over a padded input:

html
<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.

Form layout

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.

Common mistakes

  • Styling with invalid: alone, which shows errors before the user has typed.
  • Removing the outline with outline-none and providing no focus ring.
  • Using placeholder text as the only label; it disappears on input and is not announced reliably.
Quick Quiz
Question 1 of 2

Which variant shows a validation style only after the user has interacted with the field?

Key Takeaways

  • The base input is block w-full rounded-md border border-gray-300 px-3 py-2 plus focus:outline-hidden focus:ring-2; reuse it everywhere.
  • Use user-invalid: for native validation and aria-[invalid=true]: with aria-describedby for server errors.
  • Color native checkboxes and radios with accent-*; let textareas grow with field-sizing-content.
  • Icons inside inputs are absolutely positioned with pointer-events-none over a ps-10 input.
  • Lay out fields with 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.