Forms
Accessible inputs, selects, checkboxes, validation states and the @tailwindcss/forms plugin, built from Tailwind utilities.
A form is the part of an interface most likely to be driven by a keyboard, a screen reader or a password manager. The markup carries almost all of that weight, so decide on the elements and their labels first and style them second.
Start with the markup#
Every control needs a label that is programmatically associated with it — not merely sitting next to it. Association is what lets a screen reader announce the field, and what makes clicking the label focus the input.
<!-- Explicit: label points at the input's id -->
<label for="email" class="block text-sm font-medium text-gray-900">Email</label>
<input id="email" name="email" type="email" autocomplete="email"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm" />
<!-- Implicit: the input is inside the label, so no id is needed -->
<label class="block text-sm font-medium text-gray-900">
Email
<input name="email" type="email" class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm font-normal" />
</label>Explicit association is usually easier to lay out, because the label and the field are siblings you can arrange freely.
Related controls belong in a <fieldset> with a <legend> — this is the only reliable way to announce "Notification method" before the radio options themselves.
<fieldset>
<legend class="text-sm font-semibold text-gray-900">Notification method</legend>
<div class="mt-3 space-y-2">
<label class="flex items-center gap-2 text-sm text-gray-700">
<input type="radio" name="notify" value="email" class="size-4 accent-indigo-600" /> Email
</label>
<label class="flex items-center gap-2 text-sm text-gray-700">
<input type="radio" name="notify" value="sms" class="size-4 accent-indigo-600" /> SMS
</label>
</div>
</fieldset>Preflight removes the browser's default fieldset border and padding, so a fieldset is a neutral grouping box you style yourself.
The base control#
Text inputs, <textarea> and <select> can share one class list. Six things make it up:
- Box —
block w-full rounded-md px-3 py-2 - Border —
border border-gray-300, because v4 borders default tocurrentColor - Type —
text-sm text-gray-900 - Placeholder —
placeholder:text-gray-400 - Focus —
focus:outline-2 focus:outline-offset-2 focus:outline-indigo-600 - Disabled —
disabled:bg-gray-50 disabled:text-gray-500
<div class="mx-auto max-w-sm space-y-5 p-6">
<div>
<label for="name" class="block text-sm font-medium text-gray-900">Full name</label>
<input id="name" type="text" placeholder="Ada Lovelace"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-xs placeholder:text-gray-400 focus:outline-2 focus:outline-offset-2 focus:outline-indigo-600" />
</div>
<div>
<label for="bio" class="block text-sm font-medium text-gray-900">Short bio</label>
<textarea id="bio" rows="3" placeholder="Two sentences is plenty."
class="mt-1 block w-full resize-y rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-xs placeholder:text-gray-400 focus:outline-2 focus:outline-offset-2 focus:outline-indigo-600"></textarea>
</div>
<div>
<label for="role" class="block text-sm font-medium text-gray-900">Role</label>
<select id="role"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-xs focus:outline-2 focus:outline-offset-2 focus:outline-indigo-600">
<option>Engineer</option>
<option>Designer</option>
<option>Product manager</option>
</select>
</div>
<div>
<label for="locked" class="block text-sm font-medium text-gray-500">Workspace</label>
<input id="locked" type="text" value="acme-inc" disabled
class="mt-1 block w-full rounded-md border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-500 disabled:cursor-not-allowed" />
</div>
</div>Dark mode#
Native controls draw their own internals — the select arrow, the date picker, the scrollbars. Tell the browser which scheme to paint them in, or they stay light against your dark panel.
<input class="border border-gray-300 bg-white text-gray-900 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:scheme-dark" />scheme-dark sets color-scheme: dark. Applying scheme-light dark:scheme-dark on <html> once is usually enough for the whole document.
Sizing#
Keep a small, fixed set of sizes rather than inventing padding per form.
| Size | Classes | Use for |
|---|---|---|
| Small | px-2.5 py-1.5 text-sm rounded | Dense tables, filter bars |
| Medium | px-3 py-2 text-sm rounded-md | The default for most forms |
| Large | px-4 py-3 text-base rounded-md | Marketing pages, single-field forms |
Textarea#
A textarea that grows with its content used to need a JavaScript resize loop. field-sizing-content does it in CSS.
<textarea rows="2"
class="field-sizing-content max-h-40 w-full resize-none rounded-md border border-gray-300 px-3 py-2 text-sm"></textarea>rows="2"is the starting height and the fallback wherefield-sizingis unsupported.max-h-40stops the field from swallowing the page; the browser adds a scrollbar past it.resize-noneremoves the drag handle, which is redundant once the field sizes itself.
Select#
Native <select> is the most stubborn control to style. Two honest options:
- Leave the native arrow alone and only style the box. It looks native, works everywhere, and costs nothing.
- Hide it with
appearance-noneand draw your own arrow, then addpr-10so the text never runs under it.
<div class="relative">
<select class="w-full appearance-none rounded-md border border-gray-300 bg-white py-2 pr-10 pl-3 text-sm text-gray-900 focus:outline-2 focus:outline-offset-2 focus:outline-indigo-600">
<option>Monthly</option>
<option>Yearly</option>
</select>
<svg class="pointer-events-none absolute inset-y-0 right-3 my-auto size-4 text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" d="m6 9 6 6 6-6" />
</svg>
</div>pointer-events-none on the icon matters: without it, clicking the arrow does nothing because the icon eats the click instead of the select.
The dropdown list itself is drawn by the operating system and cannot be styled. If a design demands styled options, you need a listbox component with role="listbox", roving focus and full keyboard support — not a <select>.
Checkboxes and radios#
accent-* recolours the native control, which keeps the platform's checkmark, focus ring and high-contrast behaviour intact.
<div class="max-w-md space-y-6 p-6">
<fieldset>
<legend class="text-sm font-semibold text-gray-900">Email preferences</legend>
<div class="mt-3 space-y-2">
<label class="flex items-start gap-3 text-sm text-gray-700">
<input type="checkbox" checked class="mt-0.5 size-4 shrink-0 accent-indigo-600" />
<span>Product updates <span class="block text-xs text-gray-500">About once a month.</span></span>
</label>
<label class="flex items-start gap-3 text-sm text-gray-700">
<input type="checkbox" class="mt-0.5 size-4 shrink-0 accent-indigo-600" />
<span>Security alerts <span class="block text-xs text-gray-500">Sent immediately.</span></span>
</label>
</div>
</fieldset>
<fieldset>
<legend class="text-sm font-semibold text-gray-900">Plan</legend>
<div class="mt-3 grid gap-3 sm:grid-cols-2">
<label class="flex cursor-pointer items-start gap-3 rounded-lg border border-gray-300 p-4 text-sm has-checked:border-indigo-600 has-checked:bg-indigo-50 has-checked:ring-1 has-checked:ring-indigo-600">
<input type="radio" name="plan" value="starter" checked class="mt-0.5 size-4 shrink-0 accent-indigo-600" />
<span><span class="block font-medium text-gray-900">Starter</span><span class="block text-gray-500">$0 / month</span></span>
</label>
<label class="flex cursor-pointer items-start gap-3 rounded-lg border border-gray-300 p-4 text-sm has-checked:border-indigo-600 has-checked:bg-indigo-50 has-checked:ring-1 has-checked:ring-indigo-600">
<input type="radio" name="plan" value="pro" class="mt-0.5 size-4 shrink-0 accent-indigo-600" />
<span><span class="block font-medium text-gray-900">Pro</span><span class="block text-gray-500">$29 / month</span></span>
</label>
</div>
</fieldset>
</div>The selectable cards are the interesting part. has-checked: styles the label from the state of the input inside it, so the whole card reacts without any JavaScript and without a hidden input trick:
<label class="rounded-lg border border-gray-300 p-4 has-checked:border-indigo-600 has-checked:bg-indigo-50">
<input type="radio" name="plan" class="accent-indigo-600" />
Pro
</label>Because the real radio is still there and still visible, the card stays keyboard-operable and announces correctly. If you visually hide the input with sr-only, add has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-indigo-600 to the label so keyboard users can still see where they are.
File inputs#
The button inside a file input is the ::file-selector-button pseudo-element, which the file: variant targets. Note that the button inherits nothing from the input, so file:border-0 and an explicit text size are usually needed.
<label for="avatar" class="block text-sm font-medium text-gray-900">Avatar</label>
<input id="avatar" type="file" accept="image/png,image/jpeg"
class="mt-1 block w-full text-sm text-gray-600
file:mr-4 file:rounded-md file:border-0 file:bg-indigo-50 file:px-4 file:py-2
file:text-sm file:font-semibold file:text-indigo-700 hover:file:bg-indigo-100
focus:outline-2 focus:outline-offset-2 focus:outline-indigo-600" />The filename text is drawn by the browser and cannot be replaced. To control that too, hide the input with sr-only, put a <label> styled as a button next to it, and render the chosen filename yourself.
States#
| Variant | Applies when | Typical styling |
|---|---|---|
focus: | The field has focus | outline-2 outline-indigo-600 |
disabled: | disabled attribute | bg-gray-50 text-gray-500 cursor-not-allowed |
read-only: | readonly attribute | bg-gray-50 — still focusable and copyable |
required: | required attribute | Rarely styled directly; mark the label instead |
placeholder-shown: | The field is empty | Float-label effects |
autofill: | The browser filled it in | autofill:bg-yellow-50 |
<input readonly value="acct_1JX8" class="read-only:bg-gray-50 read-only:text-gray-600 …" />
<input disabled class="disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 …" />Mark required fields in the label, not only with a variant, and keep the required attribute on the input so the browser and assistive tech agree:
<label for="pw" class="block text-sm font-medium text-gray-900">
Password <span aria-hidden="true" class="text-red-600">*</span>
</label>
<input id="pw" type="password" required class="…" />Validation states#
Prefer user-valid: and user-invalid: v4.1 over valid: and invalid:. The plain pseudo-classes match from the moment the page loads, so an untouched required field is styled as an error before anyone has typed a character. The user-* variants wait until the field has actually been interacted with or the form has been submitted.
<form class="mx-auto max-w-sm space-y-4 p-6">
<div>
<label for="v-email" class="block text-sm font-medium text-gray-900">Email</label>
<input id="v-email" type="email" required placeholder="you@example.com" aria-describedby="v-email-error"
class="peer mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:outline-2 focus:outline-offset-2 focus:outline-indigo-600 user-invalid:border-red-500 user-valid:border-green-600" />
<p id="v-email-error" class="mt-1 hidden items-center gap-1 text-sm text-red-700 peer-user-invalid:flex">
<svg class="size-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path stroke-linecap="round" d="M12 8v5m0 3h.01"/></svg>
Enter a complete email address, like you@example.com.
</p>
<p class="mt-1 hidden text-sm text-green-700 peer-user-valid:block">Looks good.</p>
</div>
<p class="text-xs text-gray-500">Type an incomplete address, then click outside the field.</p>
</form>Two things are happening:
- The input carries
peer, so the message below it can react to the input's state withpeer-user-invalid:andpeer-user-valid:.peer-*only looks forward at later siblings, so the message must come after the input in the DOM. - The message toggles between
hiddenandflex, notopacity-0andopacity-100. A visually hidden but present error is still read by screen readers, which is confusing.
The @tailwindcss/forms plugin#
Browsers give form controls opinionated built-in styling that utilities cannot always reach — the size and colour of a checkbox, the padding of a select, the way a radio draws its dot. @tailwindcss/forms resets those controls to a plain, unstyled box so ordinary utilities apply predictably.
npm install -D @tailwindcss/formspnpm add -D @tailwindcss/forms@import "tailwindcss";
@plugin "@tailwindcss/forms";There is no tailwind.config.js step in v4 — @plugin in your CSS is the whole setup.
Strategies#
/* Opt-in: nothing changes until you add form-* classes */
@plugin "@tailwindcss/forms" {
strategy: class;
}| Strategy | Effect |
|---|---|
base (default) | Styles every input, select, textarea, checkbox and radio globally |
class | Styles nothing until you add form-input, form-textarea, form-select, form-multiselect, form-checkbox or form-radio |
Use class when you are adding Tailwind to an existing application that already styles its forms — the global reset would otherwise fight the old CSS.
A complete sign-up form#
Everything above, assembled: labelled fields, autocomplete hints, help text and errors tied to their inputs, a grouped choice, and a submit button.
<form class="mx-auto max-w-md space-y-5 rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
<div>
<h2 class="text-lg font-semibold text-gray-900">Create your account</h2>
<p class="mt-1 text-sm text-gray-600">Free for 14 days. No card required.</p>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label for="su-first" class="block text-sm font-medium text-gray-900">First name</label>
<input id="su-first" name="given-name" type="text" autocomplete="given-name" required
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:outline-2 focus:outline-offset-2 focus:outline-indigo-600" />
</div>
<div>
<label for="su-last" class="block text-sm font-medium text-gray-900">Last name</label>
<input id="su-last" name="family-name" type="text" autocomplete="family-name" required
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:outline-2 focus:outline-offset-2 focus:outline-indigo-600" />
</div>
</div>
<div>
<label for="su-email" class="block text-sm font-medium text-gray-900">Work email</label>
<input id="su-email" name="email" type="email" autocomplete="email" required aria-describedby="su-email-error"
class="peer mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:outline-2 focus:outline-offset-2 focus:outline-indigo-600 user-invalid:border-red-500" />
<p id="su-email-error" class="mt-1 hidden text-sm text-red-700 peer-user-invalid:block">Enter a valid email address.</p>
</div>
<div>
<label for="su-pw" class="block text-sm font-medium text-gray-900">Password</label>
<input id="su-pw" name="new-password" type="password" autocomplete="new-password" minlength="12" required aria-describedby="su-pw-help"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:outline-2 focus:outline-offset-2 focus:outline-indigo-600" />
<p id="su-pw-help" class="mt-1 text-xs text-gray-500">At least 12 characters. A passphrase is fine.</p>
</div>
<fieldset>
<legend class="text-sm font-medium text-gray-900">Team size</legend>
<div class="mt-2 flex gap-2">
<label class="flex-1 cursor-pointer rounded-md border border-gray-300 px-3 py-2 text-center text-sm text-gray-700 has-checked:border-indigo-600 has-checked:bg-indigo-50 has-checked:font-medium has-checked:text-indigo-700">
<input type="radio" name="size" value="1" checked class="sr-only" /> Just me
</label>
<label class="flex-1 cursor-pointer rounded-md border border-gray-300 px-3 py-2 text-center text-sm text-gray-700 has-checked:border-indigo-600 has-checked:bg-indigo-50 has-checked:font-medium has-checked:text-indigo-700">
<input type="radio" name="size" value="2" class="sr-only" /> 2–10
</label>
<label class="flex-1 cursor-pointer rounded-md border border-gray-300 px-3 py-2 text-center text-sm text-gray-700 has-checked:border-indigo-600 has-checked:bg-indigo-50 has-checked:font-medium has-checked:text-indigo-700">
<input type="radio" name="size" value="3" class="sr-only" /> 11+
</label>
</div>
</fieldset>
<label class="flex items-start gap-3 text-sm text-gray-700">
<input type="checkbox" required class="mt-0.5 size-4 shrink-0 accent-indigo-600" />
<span>I agree to the <a href="#" class="font-medium text-indigo-600 underline underline-offset-2">terms of service</a>.</span>
</label>
<button type="submit"
class="w-full rounded-md bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
Create account
</button>
</form>The three "team size" cards hide their radios with sr-only. That is acceptable here because the input still exists in the DOM and in the tab order — but note the trade-off from the checkbox section: a hidden input needs a visible focus style on the label, which a production version should add.
Accessibility#
- Label every control.
for/id, or wrap the input in the label. - Group related controls.
fieldset+legendfor radios and checkbox sets. - Tie help and error text to the field.
aria-describedby="field-help field-error"accepts several ids. - Flag the field itself.
aria-invalid="true"when your application knows the value is wrong. - Never rely on colour alone. A red border is invisible to many users — add an icon and a sentence of text.
- Use real autocomplete tokens.
autocomplete="email","new-password","one-time-code".
For a failed submit, move focus to a summary at the top of the form that lists the problems and links to each field. That is far more usable than leaving the user to hunt for red borders, and it works without a live region.
<div role="alert" tabindex="-1" class="rounded-md border border-red-200 bg-red-50 p-4 text-sm text-red-800">
<p class="font-semibold">Two fields need attention</p>
<ul class="mt-2 list-disc space-y-1 pl-5">
<li><a href="#su-email" class="underline">Work email — enter a valid address</a></li>
<li><a href="#su-pw" class="underline">Password — use at least 12 characters</a></li>
</ul>
</div>Common mistakes#
- Placeholders used as labels. They vanish on input and are read unreliably.
invalid:instead ofuser-invalid:. Every empty required field turns red before the user has done anything.- Colour-only error states. A red border communicates nothing to a colour-blind or screen-reader user; add text and an icon.
borderwithout a colour in v4. The default iscurrentColor, notgray-200.- Errors that are hidden with
opacity-0. They stay in the accessibility tree and get announced. Usehidden/block. disabledon fields whose value still matters. Disabled values are not submitted and cannot be read comfortably — usereadonly.- Missing
type="button"on secondary buttons inside a form. The default issubmit. - Font size below 16px on mobile inputs. iOS zooms the viewport on focus.
- Rebuilding
<select>as divs. A styled listbox needs full keyboard and ARIA support; the native control is nearly always the better trade.
Related topics#
- Buttons — submit buttons, loading and disabled states
- States & Variants — the full validation variant table
- Plugins —
@pluginand what else is worth installing - Modals & Toasts — forms inside a
<dialog> - Dark Mode — pairing control colours