08 · Styling Forms¶
Forms are where Tailwind beginners get the biggest surprise: drop an <input> on a page
and it's invisible. No border, no background, just a blinking caret. That's Preflight
(Tailwind's base styles) resetting form controls so they look the same in every browser,
which also means you're responsible for styling them. This lesson covers what the reset
does, a solid pattern for text fields, checkboxes, radios and selects, validation and
error messages, and when the official forms plugin is worth adding.
What Preflight does to form controls¶
These rules come from Tailwind 4.3.3's base layer:
*, ::after, ::before, ::backdrop, ::file-selector-button {
box-sizing: border-box;
margin: 0;
padding: 0;
border: 0 solid;
}
button, input, select, optgroup, textarea, ::file-selector-button {
font: inherit;
color: inherit;
border-radius: 0;
background-color: transparent;
/* … */
}
::placeholder { opacity: 1; color: color-mix(in oklab, currentcolor 50%, transparent); }
textarea { resize: vertical; }
We rendered a bare <input> and measured border-top-width: 0px and a transparent
background. That's why it disappears. The upside is that controls now use your page
font at your page size, instead of each browser's small system default.
A text field done properly¶
<div>
<label for="email" class="block text-sm font-medium text-gray-900">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required
aria-describedby="email-hint"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2
text-gray-900 shadow-xs placeholder:text-gray-400
focus:border-sky-600 focus:ring-2 focus:ring-sky-600/30 focus:outline-hidden
disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500
read-only:bg-gray-50">
<p id="email-hint" class="mt-1 text-sm text-gray-500">We'll only use this for receipts.</p>
</div>
The pieces that matter:
- A visible
<label>tied withfor/id. Clicking it focuses the input, and screen readers announce it. A placeholder is not a label. aria-describedbyconnects the hint, so it's read after the label.- Border and background put back what Preflight removed.
- Focus: the default outline is removed only because the border colour and a ring
replace it, and it's removed with
outline-hiddenrather thanoutline-none. Rings are box-shadows, which Windows forced-colours mode strips out;outline-hiddenrestores a real outline in that mode (Level 3 · 04). Text inputs always match:focus-visibleas well, sofocus:andfocus-visible:behave the same here. autocompleteand the righttypegive users the right mobile keyboard and autofill. They're not styling, but they matter more than any class.
Validation and error messages¶
Use user-invalid: for styling, so fields don't show errors before the user has touched
them (Level 1 · 08):
<div>
<label for="zip" class="block text-sm font-medium text-gray-900">Postcode</label>
<input id="zip" name="zip" required pattern="[0-9]{5}" inputmode="numeric"
aria-describedby="zip-error"
class="peer mt-1 block w-full rounded-md border border-gray-300 px-3 py-2
focus:border-sky-600 focus:ring-2 focus:ring-sky-600/30 focus:outline-hidden
user-invalid:border-red-600 user-invalid:focus:ring-red-600/30">
<p id="zip-error" class="mt-1 hidden text-sm text-red-700 peer-user-invalid:block">
Enter a 5-digit postcode.
</p>
</div>
Three accessibility details:
- Don't rely on colour alone. The message text is the real signal; the red border is a reinforcement.
- Server-side errors need more than CSS. After a failed submit, render the field with
aria-invalid="true"and the message visible, then style witharia-invalid:border-red-600. - Wrap each field and its message in its own element so
peercan't leak between fields (the unnamed-peer problem from Level 1 · 08).
Checkboxes and radios: accent-* first¶
The cheapest way to brand native checkboxes, radios and range sliders is accent-color.
It keeps the native control, so keyboard behaviour, high-contrast mode and screen reader
support stay intact:
<label class="flex items-start gap-3">
<input type="checkbox" name="terms" required class="mt-1 size-4 accent-sky-600">
<span class="text-sm text-gray-700">I agree to the <a class="underline" href="/terms">terms</a>.</span>
</label>
Wrapping the input in its <label> makes the whole text clickable. mt-1 aligns the box
with the first line of text when the label wraps.
If the design needs a fully custom look, the pattern from Level 1 · 08 applies: keep a
real (visually hidden) input and style a sibling with peer-checked: and
peer-focus-visible:, or style the parent with has-checked:.
Selects¶
A native <select> with Preflight keeps its arrow in most browsers but looks
inconsistent. A dependable approach is to remove the native appearance and draw your own
arrow:
<div class="relative">
<label for="country" class="block text-sm font-medium text-gray-900">Country</label>
<select id="country" name="country"
class="mt-1 block w-full appearance-none rounded-md border border-gray-300 bg-white
py-2 ps-3 pe-10 focus:border-sky-600 focus:ring-2 focus:ring-sky-600/30
focus:outline-hidden">
<option>India</option>
<option>Canada</option>
</select>
<svg class="pointer-events-none absolute end-3 bottom-2.5 size-5 text-gray-500"
aria-hidden="true" viewBox="0 0 20 20" fill="currentColor">
<path d="M5.3 7.3a1 1 0 0 1 1.4 0L10 10.6l3.3-3.3a1 1 0 1 1 1.4 1.4l-4 4a1 1 0 0 1-1.4 0l-4-4a1 1 0 0 1 0-1.4z"/>
</svg>
</div>
pe-10 reserves room so long option text doesn't run under the arrow, and
pointer-events-none lets clicks on the arrow reach the select.
Textareas that grow: field-sizing-content¶
field-sizing-content compiles to field-sizing: content, which makes a textarea grow
with its text instead of scrolling. In Chromium, a four-line textarea measured 96px with
it and 48px (the default two rows) without it. Browser support is still incomplete, so
treat it as an enhancement: add min-h-24 so it's usable either way, and don't depend
on it for layout.
The forms plugin: when to add it¶
@tailwindcss/forms gives every form control a consistent, styled-but-plain default
(border, padding, white background, focus ring), so you don't have to start from
nothing. In v4 you load plugins from CSS:
With version 0.5.11, this added rules in the base layer such as:
input:where([type='text']), input:where(:not([type])), input:where([type='email']), …,
textarea, select {
appearance: none;
background-color: #fff;
border-color: oklch(55.1% 0.027 264.364);
border-width: 1px;
padding-top: 0.5rem; /* … */
}
Because it's in the base layer with :where(), any utility you add still overrides it.
If you'd rather opt in per element, use the class strategy:
Then nothing is styled globally, and you add form-input, form-select,
form-checkbox where you want them. The class strategy is the safer choice when
adding the plugin to an existing site, since the global strategy restyles every input on
every page, including third-party widgets.
Add the plugin when you have many forms and want consistent defaults. Skip it when your forms are few, or when you're using a component library that styles its own controls.
Worked example: a sign-up form¶
<form class="mx-auto max-w-md space-y-5" action="/signup" method="post" novalidate>
<div>
<label for="name" class="block text-sm font-medium">Full name</label>
<input id="name" name="name" autocomplete="name" required
class="peer mt-1 block w-full rounded-md border border-gray-300 px-3 py-2
focus:border-sky-600 focus:ring-2 focus:ring-sky-600/30 focus:outline-hidden
user-invalid:border-red-600">
<p class="mt-1 hidden text-sm text-red-700 peer-user-invalid:block">Enter your name.</p>
</div>
<div>
<label for="pw" class="block text-sm font-medium">Password</label>
<input id="pw" name="password" type="password" autocomplete="new-password"
minlength="12" required aria-describedby="pw-hint"
class="peer mt-1 block w-full rounded-md border border-gray-300 px-3 py-2
focus:border-sky-600 focus:ring-2 focus:ring-sky-600/30 focus:outline-hidden
user-invalid:border-red-600">
<p id="pw-hint" class="mt-1 text-sm text-gray-500 peer-user-invalid:text-red-700">
At least 12 characters.
</p>
</div>
<fieldset>
<legend class="text-sm font-medium">Plan</legend>
<div class="mt-2 grid gap-3 sm:grid-cols-2">
<label class="flex cursor-pointer gap-3 rounded-lg border border-gray-300 p-3
has-checked:border-sky-600 has-checked:bg-sky-50">
<input type="radio" name="plan" value="free" checked class="mt-0.5 accent-sky-600">
<span><span class="block font-medium">Free</span>
<span class="block text-sm text-gray-500">For personal use</span></span>
</label>
<label class="flex cursor-pointer gap-3 rounded-lg border border-gray-300 p-3
has-checked:border-sky-600 has-checked:bg-sky-50">
<input type="radio" name="plan" value="team" class="mt-0.5 accent-sky-600">
<span><span class="block font-medium">Team</span>
<span class="block text-sm text-gray-500">Shared workspaces</span></span>
</label>
</div>
</fieldset>
<button class="w-full rounded-md bg-sky-700 px-4 py-2.5 font-medium text-white
hover:bg-sky-800 focus-visible:outline-2 focus-visible:outline-offset-2
focus-visible:outline-sky-600">Create account</button>
</form>
Notes:
novalidateturns off the browser's own error bubbles so your inline messages are the only ones. Without it, users get both. You must then check validity in JavaScript on submit (form.checkValidity()), focus the first invalid field, and still validate on the server.- The password hint turns red instead of a separate error appearing. The rule stays visible, so users know what's expected before they make a mistake.
- Radios in a
<fieldset>with a<legend>are announced as a group ("Plan, Free, radio button, 1 of 2").
How It Actually Works¶
Native form controls are partly drawn by the operating system. CSS can restyle the
"outside" (border, background, padding, font), and appearance: none asks the browser to
drop the native drawing so your styles take over completely. Preflight does the minimum
(inherit fonts and colours, remove borders and backgrounds) and doesn't set
appearance: none on checkboxes, radios or selects, which is why they still look
native. accent-color is a separate hook the
browser gives you to recolour native controls without replacing them.
The forms plugin is a set of base styles written with :where() selectors, so their
specificity is that of the element alone. Utility classes, being one class, always beat
them. Your validation variants (user-invalid:, peer-user-invalid:) are ordinary
pseudo-class selectors, re-evaluated by the browser as the user types and blurs.
Common mistakes¶
- Shipping an unstyled input after Preflight removed its border.
- Placeholder instead of a label.
focus:outline-nonewith no replacement, or with only a ring, which disappears in forced-colours mode. Usefocus:outline-hiddenplus a ring.- Showing errors on page load with
invalid:. Useuser-invalid:and server-sidearia-invalid. - Colour-only error states, with no message text.
- Rebuilding checkboxes from scratch when
accent-*would do. - Adding the forms plugin globally to an existing site and restyling every input
everywhere. Consider
strategy: "class". novalidatewithout JavaScript validation, so invalid forms submit.
Exercise¶
- Drop a bare
<input>and<select>on a page and inspect them. Then style a text field with label, hint, focus ring and disabled state. - Build the sign-up form. Test it with only the keyboard and with a screen reader.
- Add the forms plugin with the class strategy, convert one field to
form-input, and compare its compiled CSS to your hand-written classes. - Add a textarea with
field-sizing-contentand amin-h-*, and check it in a browser that doesn't supportfield-sizing.