To style forms with Tailwind CSS, apply utilities directly to each control: a border color, padding, a radius and a focus-visible: ring for inputs, accent-* for native checkboxes and radios, and appearance-none when you want full control over a select. For consistent defaults across every control, add the @tailwindcss/forms plugin with @plugin "@tailwindcss/forms"; in your CSS.
This guide covers both approaches in Tailwind CSS v4, the v4 changes that affect forms, and validation and accessibility patterns that hold up in production.
Table of contents
- What changed for forms in v4
- Text inputs and textareas
- Selects, checkboxes and radios
- Using the @tailwindcss/forms plugin
- Validation and state variants
- A complete newsletter form
- FAQ
What changed for forms in v4
A few default changes in Tailwind v4 show up most often in forms. Knowing them saves debugging time when upgrading older markup:
- Border color defaults to
currentColor. In v3 a plainborderwas gray-200. In v4 it uses the text color, so always pairborderwith a color likeborder-neutral-300. ringis 1px. The bareringclass used to be 3px. Usering-2orring-3for visible focus rings.outline-hiddenreplaces the oldoutline-none. In v4,outline-hiddenhides the outline but keeps it in forced-colors mode, whileoutline-nonesetsoutline-style: noneoutright.- Placeholder color now defaults to the current text color at 50% opacity rather than a fixed gray.
- Buttons use
cursor: default, matching the browser. Addcursor-pointerif you want the hand cursor.
Text inputs and textareas
A good base input needs a width, padding, a border with a color, a radius, a background for dark mode, and a visible focus state:
<label for="email" class="block text-sm font-medium text-neutral-900 dark:text-white">
Email
</label>
<input
id="email"
type="email"
autocomplete="email"
placeholder="[email protected]"
class="mt-2 block w-full rounded-lg border border-neutral-300 bg-white px-3.5 py-2.5 text-sm
text-neutral-900 placeholder:text-neutral-400 outline-hidden
focus-visible:border-indigo-500 focus-visible:ring-3 focus-visible:ring-indigo-500/20
dark:border-neutral-700 dark:bg-neutral-900 dark:text-white"
/>
Use focus-visible: rather than focus: for buttons and links so mouse users don't see a ring after clicking. For text inputs, browsers treat any focus as visible, so both behave the same; focus-visible: keeps the pattern consistent.
Auto-growing textareas
v4 includes field-sizing-content, which lets a textarea grow with its content without JavaScript. Pair it with a minimum height so it doesn't start as a single line:
<textarea
rows="3"
class="block w-full field-sizing-content min-h-24 rounded-lg border border-neutral-300 px-3.5 py-2.5
dark:border-neutral-700 dark:bg-neutral-900"
></textarea>
Browser support for field-sizing is still incomplete, so treat it as a progressive enhancement. In browsers without it, the textarea simply stays at its rows height.
File inputs
The file: variant styles the button inside a file input: file:mr-4 file:rounded-md file:border-0 file:bg-indigo-50 file:px-3 file:py-2 file:text-indigo-700.
Selects, checkboxes and radios
Native checkboxes with accent color
The simplest approach for checkboxes and radios is to keep the native control and color it with accent-*. It's accessible by default and works in light and dark mode:
<label class="flex items-center gap-3 text-sm">
<input type="checkbox" class="size-4 accent-indigo-600" />
Send me product updates
</label>
Wrapping the input in its label makes the whole row clickable and connects the label for screen readers without an id.
Custom selects
Native select arrows look different in every browser. Remove them with appearance-none, then place your own icon in a wrapper using grid stacking so the icon doesn't block clicks:
<div class="grid">
<select
class="col-start-1 row-start-1 w-full appearance-none rounded-lg border border-neutral-300 bg-white
py-2.5 pl-3.5 pr-10 text-sm dark:border-neutral-700 dark:bg-neutral-900"
>
<option>Starter</option>
<option>Team</option>
<option>Enterprise</option>
</select>
<svg class="pointer-events-none col-start-1 row-start-1 mr-3 size-4 self-center justify-self-end text-neutral-500"
viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z" />
</svg>
</div>
Fully custom checkboxes
If you need a design the accent color can't produce, use appearance-none on the checkbox and draw the checked state with checked: utilities, or use a visually hidden input with peer and style a sibling with peer-checked:. Keep the real input in the DOM so keyboard and screen reader behavior still work, and give it a visible focus-visible: ring.
Using the @tailwindcss/forms plugin
The official forms plugin applies a reset to form controls so they look consistent across browsers and are easy to restyle with utilities. Install it and register it in CSS:
npm install -D @tailwindcss/forms
/* app.css */
@import "tailwindcss";
@plugin "@tailwindcss/forms";
By default the plugin styles elements globally by type, such as input[type="text"], select and textarea. If you only want its styles on elements you opt in, switch to the class strategy:
@plugin "@tailwindcss/forms" {
strategy: "class";
}
Then add form-input, form-select, form-textarea, form-checkbox or form-radio to each control. The class strategy is the safer choice when adding the plugin to an existing project or when mixing in third-party widgets. With the plugin active, checkboxes accept text color utilities, so text-indigo-600 sets the checked color.
Validation and state variants
Tailwind has variants for every form state the browser exposes, so most validation styling needs no JavaScript:
invalid:andvalid:match the:invalidand:validpseudo-classes.user-invalid:anduser-valid:only apply after the user has interacted with the field, which avoids showing errors on an untouched form.required:,disabled:,read-only:,placeholder-shown:andautofill:cover the other common states.peer-*andgroup-has-*let siblings and parents react to a field's state.
<div>
<input
type="email"
required
class="peer block w-full rounded-lg border border-neutral-300 px-3.5 py-2.5
user-invalid:border-red-500 user-invalid:ring-3 user-invalid:ring-red-500/20"
/>
<p class="mt-2 hidden text-sm text-red-600 peer-user-invalid:block">
Enter a valid email address.
</p>
</div>
For server-side errors, set aria-invalid="true" on the field and style it with the aria-invalid: variant. Connect the error message with aria-describedby so screen readers announce it.
Disabled controls need two signals: reduced opacity and a non-interactive cursor, for example disabled:cursor-not-allowed disabled:opacity-50. The has-disabled: variant on a wrapping label can dim the label text too.
A complete newsletter form
Putting it together, here's a single-row signup form that stacks on mobile, has a visually hidden label, and styles focus and disabled states:
<form class="flex flex-col gap-3 sm:flex-row" action="/subscribe" method="post">
<label for="newsletter-email" class="sr-only">Email address</label>
<input
id="newsletter-email"
name="email"
type="email"
required
autocomplete="email"
placeholder="Enter your email"
class="min-w-0 flex-1 rounded-lg border border-neutral-300 bg-white px-4 py-3 text-sm outline-hidden
focus-visible:border-indigo-500 focus-visible:ring-3 focus-visible:ring-indigo-500/20
dark:border-neutral-700 dark:bg-neutral-900 dark:text-white"
/>
<button
type="submit"
class="cursor-pointer rounded-lg bg-indigo-600 px-5 py-3 text-sm font-medium text-white
hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600
disabled:cursor-not-allowed disabled:opacity-50"
>
Subscribe
</button>
</form>
The min-w-0 on the input lets it shrink inside the flex row instead of pushing the button off-screen on narrow devices.
More form layouts are in the CTA blocks and footer blocks, including subscribe cards and contact forms in light and dark mode. If you'd rather not hand-code it, Lindo.ai can build a site from any OpenTailwind template with AI.
FAQ
Do I need the @tailwindcss/forms plugin?
No. You can style every control with plain utilities, as shown above. The plugin is useful when you have many forms and want consistent cross-browser defaults without repeating base classes everywhere.
Why is my input border black after upgrading to v4?
In v4, border uses currentColor by default instead of gray-200. Add an explicit color such as border-neutral-300, or set a global default in your base layer.
How do I remove the blue focus outline but stay accessible?
Replace it rather than removing it. Use outline-hidden together with a focus-visible:ring-* or focus-visible:outline-* style that has enough contrast against the background.
How do I show errors only after the user types?
Use the user-invalid: variant. It matches :user-invalid, which only applies after the user has changed the field or tried to submit the form.
Tailwind CSS v4 gives you everything needed to build forms without writing custom CSS: base utilities for inputs, accent-* and appearance-none for native controls, state variants like user-invalid: and disabled:, and an optional plugin for consistent defaults. Set explicit border colors, use visible focus rings, keep labels connected to their fields, and your forms will look consistent and work for keyboard and screen reader users.