Tailwind CSS Handbook v4.3

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.

HTML
<!-- 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.

HTML
<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:

  1. Box — block w-full rounded-md px-3 py-2
  2. Border — border border-gray-300, because v4 borders default to currentColor
  3. Type — text-sm text-gray-900
  4. Placeholder — placeholder:text-gray-400
  5. Focus — focus:outline-2 focus:outline-offset-2 focus:outline-indigo-600
  6. Disabled — disabled:bg-gray-50 disabled:text-gray-500
Loading preview…

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.

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

SizeClassesUse for
Smallpx-2.5 py-1.5 text-sm roundedDense tables, filter bars
Mediumpx-3 py-2 text-sm rounded-mdThe default for most forms
Largepx-4 py-3 text-base rounded-mdMarketing 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.

HTML
<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 where field-sizing is unsupported.
  • max-h-40 stops the field from swallowing the page; the browser adds a scrollbar past it.
  • resize-none removes the drag handle, which is redundant once the field sizes itself.

Select#

Native <select> is the most stubborn control to style. Two honest options:

  1. Leave the native arrow alone and only style the box. It looks native, works everywhere, and costs nothing.
  2. Hide it with appearance-none and draw your own arrow, then add pr-10 so the text never runs under it.
HTML
<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.

Loading preview…

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:

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

HTML
<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#

VariantApplies whenTypical styling
focus:The field has focusoutline-2 outline-indigo-600
disabled:disabled attributebg-gray-50 text-gray-500 cursor-not-allowed
read-only:readonly attributebg-gray-50 — still focusable and copyable
required:required attributeRarely styled directly; mark the label instead
placeholder-shown:The field is emptyFloat-label effects
autofill:The browser filled it inautofill:bg-yellow-50
HTML
<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:

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

Loading preview…

Two things are happening:

  • The input carries peer, so the message below it can react to the input's state with peer-user-invalid: and peer-user-valid:. peer-* only looks forward at later siblings, so the message must come after the input in the DOM.
  • The message toggles between hidden and flex, not opacity-0 and opacity-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.

BASH
npm install -D @tailwindcss/forms
app.css
@import "tailwindcss";
@plugin "@tailwindcss/forms";

There is no tailwind.config.js step in v4 — @plugin in your CSS is the whole setup.

Strategies#

CSS
/* Opt-in: nothing changes until you add form-* classes */
@plugin "@tailwindcss/forms" {
  strategy: class;
}
StrategyEffect
base (default)Styles every input, select, textarea, checkbox and radio globally
classStyles 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.

Loading preview…

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#

  1. Label every control. for/id, or wrap the input in the label.
  2. Group related controls. fieldset + legend for radios and checkbox sets.
  3. Tie help and error text to the field. aria-describedby="field-help field-error" accepts several ids.
  4. Flag the field itself. aria-invalid="true" when your application knows the value is wrong.
  5. Never rely on colour alone. A red border is invisible to many users — add an icon and a sentence of text.
  6. 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.

HTML
<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 of user-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.
  • border without a colour in v4. The default is currentColor, not gray-200.
  • Errors that are hidden with opacity-0. They stay in the accessibility tree and get announced. Use hidden / block.
  • disabled on fields whose value still matters. Disabled values are not submitted and cannot be read comfortably — use readonly.
  • Missing type="button" on secondary buttons inside a form. The default is submit.
  • 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.