Styling Forms

Tailwind CSS · Lesson 19 of 27 7 min read Updated

Forms are where visitors actually do things: register for a course, book a slot, pay a fee. They need to be clear, comfortable to tap on a phone and honest about errors. Tailwind gives you full control over every control's look, but it also starts from a blank slate, so you need to know what to add.

In this lesson you will style text inputs, selects and textareas, show validation errors at the right moment, colour checkboxes, radio buttons and range sliders with accent-*, style the file upload button, and meet the official forms plugin.

What Preflight does to form controls

Preflight makes form controls inherit the page's font and text colour, and removes their background, border and rounded corners. That means a plain <input> in a Tailwind page is nearly invisible: just a blinking cursor on the page. This is deliberate. You decide exactly how controls look, and they always use your site's font, which browsers do not do by default.

So every input needs at least a border, some padding and a focus style:

HTML
<input type="text" class="w-full rounded-lg border border-slate-300 bg-white px-3 py-2
       placeholder:text-slate-400 focus:border-sky-600 focus:outline-2 focus:outline-sky-600/25">

focus:outline-2 focus:outline-sky-600/25 draws a soft 2px outline when the field is active, together with a stronger border colour. Never remove the focus style completely; people using a keyboard rely on it.

A complete form

Here is a registration form for a coding workshop with labels, help text, a select, a textarea and validation:

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Workshop registration</title>
  <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4.3.3"></script>
</head>
<body class="bg-slate-100 p-6">
  <form class="mx-auto flex max-w-md flex-col gap-5 rounded-2xl bg-white p-6 shadow-sm">
    <h1 class="text-xl font-bold">Register: Web workshop, Kochi</h1>
    <div>
      <label for="name" class="block text-sm font-medium text-slate-700">Full name</label>
      <input id="name" name="name" required autocomplete="name" class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 focus:border-sky-600 focus:outline-2 focus:outline-sky-600/25 user-invalid:border-red-500">
    </div>
    <div>
      <label for="phone" class="block text-sm font-medium text-slate-700">Mobile number</label>
      <input id="phone" name="phone" type="tel" required pattern="[6-9][0-9]{9}" inputmode="numeric" aria-describedby="phone-help" placeholder="98xxxxxxxx" class="peer mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 placeholder:text-slate-400 focus:border-sky-600 focus:outline-2 focus:outline-sky-600/25 user-invalid:border-red-500 user-valid:border-green-600">
      <p id="phone-help" class="mt-1 text-sm text-slate-500 peer-user-invalid:text-red-600">10 digits, starting with 6, 7, 8 or 9.</p>
    </div>
    <div>
      <label for="batch" class="block text-sm font-medium text-slate-700">Batch</label>
      <select id="batch" name="batch" class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 focus:border-sky-600 focus:outline-2 focus:outline-sky-600/25">
        <option>Saturday morning</option>
        <option>Sunday afternoon</option>
      </select>
    </div>
    <div>
      <label for="why" class="block text-sm font-medium text-slate-700">Why do you want to join? <span class="font-normal text-slate-500">(optional)</span></label>
      <textarea id="why" name="why" rows="3" class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 field-sizing-content focus:border-sky-600 focus:outline-2 focus:outline-sky-600/25"></textarea>
    </div>
    <button class="rounded-lg bg-sky-600 py-2.5 font-semibold text-white hover:bg-sky-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-600">Register</button>
  </form>
</body>
</html>

Type a wrong mobile number such as "12345" and move to the next field: the border turns red and the help text turns red too, through peer-user-invalid:. Type a valid one and the border turns green. The textarea grows as you type, because field-sizing-content lets it size itself to its content (in browsers that support field-sizing; elsewhere it simply keeps its rows height).

Validation states: invalid vs user-invalid

Browsers check required, type="email", pattern and similar attributes for you (see Built-in Form Validation). Tailwind can style the result:

VariantApplies when
invalid:The value is invalid right now, even before the user touches the field
user-invalid:The value is invalid and the user has interacted with the field
user-valid:The value is valid and the user has interacted with it
required:The field has the required attribute
aria-invalid:The field has aria-invalid="true", for errors your server found

invalid: turns every empty required field red the moment the page loads, which feels like being scolded before you start. user-invalid: waits until the visitor has changed the field, so it is the better default. For errors found by the server, such as "this email is already registered", set aria-invalid="true" on the field and style it with aria-invalid:border-red-500; screen readers announce the attribute as well.

Checkboxes, radios, ranges and files

You rarely need to rebuild checkboxes from scratch. accent-* colours the browser's own checkbox, radio button, range slider and progress bar, and they stay fully accessible:

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Accent and file inputs</title>
  <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4.3.3"></script>
</head>
<body class="flex max-w-md flex-col gap-6 p-6">
  <label class="flex items-center gap-2">
    <input type="checkbox" checked class="size-4 accent-emerald-600">
    Send me the timetable by SMS
  </label>
  <fieldset class="flex gap-6">
    <legend class="mb-2 text-sm font-medium">Mode</legend>
    <label class="flex items-center gap-2"><input type="radio" name="mode" checked class="size-4 accent-emerald-600"> Classroom</label>
    <label class="flex items-center gap-2"><input type="radio" name="mode" class="size-4 accent-emerald-600"> Online</label>
  </fieldset>
  <label class="flex flex-col gap-1 text-sm font-medium">
    Hours per week
    <input type="range" min="2" max="20" value="6" class="accent-emerald-600">
  </label>
  <label class="flex flex-col gap-1 text-sm font-medium">
    Upload your photo
    <input type="file" accept="image/*" class="text-sm text-slate-600 file:me-3 file:rounded-full file:border-0 file:bg-emerald-50 file:px-4 file:py-2 file:font-semibold file:text-emerald-700 hover:file:bg-emerald-100">
  </label>
</body>
</html>

The file: variant styles the "Choose file" button inside the file input, which is otherwise very hard to style. Notice hover:file:bg-emerald-100: variants stack, so the button changes colour when you hover over the input.

If a design really needs a custom-drawn checkbox, add appearance-none to remove the browser's drawing, then style checked: states yourself, or use the peer technique from Group, Peer & has() Variants. Keep the real <input> in the page so it works with the keyboard and screen readers.

The forms plugin

Writing border, padding and focus classes on every control is repetitive. The official forms plugin gives all controls a sensible, consistent starting style that is easy to override with utilities:

Terminal
npm install -D @tailwindcss/forms
CSS
@import "tailwindcss";
@plugin "@tailwindcss/forms";

With it, inputs, selects (including a drawn arrow), checkboxes and radios look like neat, plain controls without any classes, and you add utilities only to change things, such as rounded-lg or text-sky-600 for checkbox colour. In v3 you added it to the plugins array in tailwind.config.js; in v4 you use @plugin in CSS.

Advanced: one input style, many fields

Long class lists on every input are hard to maintain. In a real project you write the input once as a component (a React component, a Blade component or a template partial) and reuse it, which the lesson Reusable Components covers. If you are not using components, @apply in your CSS is a reasonable choice for form controls, because they are small, repeated and rarely change. See Custom Utilities, Variants & @apply.

Common mistakes

  • Inputs with no border. Preflight removes it; always add border and a border colour, or use the forms plugin.
  • Placeholder instead of label. Placeholders disappear when you type and are often low-contrast. Always use a <label>.
  • Errors on page load. Use user-invalid: instead of invalid: for red borders and messages.
  • Tiny tap targets. On phones, aim for controls around 44px tall: py-2.5 with text-base works well.
  • Colour-only errors. Add a text message, not only a red border, and connect it with aria-describedby.

Key points

  • Preflight removes form control borders and backgrounds, so you style inputs from scratch or use the forms plugin.
  • Give every control a border, padding and a visible focus style such as focus:outline-2.
  • user-invalid: and user-valid: show validation only after interaction; aria-invalid: styles server-side errors.
  • accent-* colours native checkboxes, radios and ranges; file: styles the file button; placeholder: styles placeholder text.
  • field-sizing-content lets a textarea grow with its content.
  • @plugin "@tailwindcss/forms"; loads the official forms plugin in v4.

Exercise

Build a small "Book a library seat" form for a library in Lucknow with an email field, a date field and a checkbox "I agree to the library rules". The email field must show a red border and a red message "Enter a valid email" only after the user has typed something invalid. The checkbox should use an indigo accent colour, and the submit button should have a visible focus outline.

Show solution
HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Library seat</title>
  <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4.3.3"></script>
</head>
<body class="bg-slate-100 p-6">
  <form class="mx-auto flex max-w-sm flex-col gap-4 rounded-xl bg-white p-6">
    <div>
      <label for="email" class="block text-sm font-medium">Email</label>
      <input id="email" type="email" required class="peer mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 focus:outline-2 focus:outline-indigo-600/30 user-invalid:border-red-500">
      <p class="mt-1 hidden text-sm text-red-600 peer-user-invalid:block">Enter a valid email</p>
    </div>
    <div>
      <label for="day" class="block text-sm font-medium">Date</label>
      <input id="day" type="date" required class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2">
    </div>
    <label class="flex items-center gap-2 text-sm">
      <input type="checkbox" required class="size-4 accent-indigo-600"> I agree to the library rules
    </label>
    <button class="rounded-lg bg-indigo-600 py-2 font-semibold text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Book seat</button>
  </form>
</body>
</html>

The message is hidden until the email input (the peer before it) matches :user-invalid, and accent-indigo-600 recolours the native checkbox without losing its accessibility.

Quick Quiz

1. Why does a plain <input> look almost invisible in a Tailwind page?

2. Which variant shows an error style only after the user has interacted with a field?

3. What is the simplest way to make native checkboxes and radio buttons green?

4. How do you load the official forms plugin in Tailwind v4?

Learn Tailwind CSS with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call