splitforms.com
guide · form validation

Tailwind CSS Form Validation — Working Code

Tailwind ships `invalid:`, `valid:`, `focus:`, and `placeholder-shown:` variants that map directly to native HTML validation pseudo-classes. No JavaScript validation library — just utility classes and the platform.

html
<form action="https://splitforms.com/api/submit" method="POST" class="grid gap-4 max-w-md">
  <input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />

  <label class="grid gap-1.5">
    <span class="text-sm font-medium text-zinc-900 dark:text-zinc-100">Name</span>
    <input
      name="name"
      type="text"
      required
      minlength="2"
      placeholder=" "
      class="
        peer
        rounded-lg px-3 py-2.5
        border border-zinc-300 dark:border-zinc-700
        bg-white dark:bg-zinc-900
        text-zinc-900 dark:text-zinc-100
        focus:outline-none focus:border-orange-500 focus:ring-2 focus:ring-orange-500/20
        invalid:[&:not(:placeholder-shown)]:border-red-500
        invalid:[&:not(:placeholder-shown)]:ring-red-500/20
      "
    />
    <span class="text-xs text-red-500 hidden peer-invalid:peer-[:not(:placeholder-shown)]:block">
      Name must be at least 2 characters.
    </span>
  </label>

  <label class="grid gap-1.5">
    <span class="text-sm font-medium text-zinc-900 dark:text-zinc-100">Email</span>
    <input
      name="email"
      type="email"
      required
      placeholder=" "
      class="
        peer
        rounded-lg px-3 py-2.5
        border border-zinc-300 dark:border-zinc-700
        bg-white dark:bg-zinc-900
        text-zinc-900 dark:text-zinc-100
        focus:outline-none focus:border-orange-500 focus:ring-2 focus:ring-orange-500/20
        invalid:[&:not(:placeholder-shown)]:border-red-500
      "
    />
  </label>

  <button
    type="submit"
    class="rounded-full bg-zinc-900 dark:bg-zinc-100 text-zinc-100 dark:text-zinc-900 px-5 py-2.5 font-semibold hover:bg-orange-500 dark:hover:bg-orange-500"
  >
    Send
  </button>
</form>

Tailwind's validation strategy: lean on the platform. Native HTML validation (`required`, `type=email`, `pattern`, `minlength`) handles the rules; Tailwind's `invalid:`, `valid:`, `focus:`, and `placeholder-shown:` variants handle the styling. Together they produce a polished error UI with zero JavaScript validation library.

The trick to delaying error styling until the user has typed is `invalid:[&:not(:placeholder-shown)]:border-red-500`. This is a Tailwind arbitrary variant: 'apply red border when the input is invalid AND the placeholder is not shown'. Combined with `placeholder=" "` (a non-breaking space, so the placeholder-shown trick works), the error state only kicks in after the user has typed something.

The `peer` utility lets sibling elements respond to the input's state. `peer-invalid:peer-[:not(:placeholder-shown)]:block` on a sibling error message means 'show this error when the peer input is invalid AND has been typed in'. Useful for inline error text that appears below the field.

Dark mode is automatic via the `dark:` variant. The form above ships both light and dark mode in the same markup; the user's OS preference decides which renders. Tailwind's `darkMode: 'media'` (the default) reads `prefers-color-scheme`; switch to `darkMode: 'class'` if you want a manual toggle.

How to set this up

Step 01

Use native HTML validation attributes

required, type=email, pattern, minlength. The browser does the validation; Tailwind handles the styling.

Step 02

Apply the invalid: variant

invalid:border-red-500 styles the input when invalid. Pair with :not(:placeholder-shown) to delay until typed.

Step 03

Use peer for sibling error messages

peer on the input, peer-invalid: on the error message. Error text shows when the input is invalid.

Step 04

Add dark mode for free

dark: variants on every color class. prefers-color-scheme decides which renders. No theme switcher needed.

invalid: + peer + dark: variants. Polished form, no JS library.

Frequently asked questions

Does Tailwind have built-in form validation?

Tailwind ships variants (invalid:, valid:, focus:) that map to native HTML validation pseudo-classes. There's no JavaScript validation library — Tailwind styles what the browser already validates.

How do I show an error message only after the user types?

Combine invalid: with :not(:placeholder-shown): `invalid:[&:not(:placeholder-shown)]:border-red-500`. Set placeholder=" " (non-breaking space) so :placeholder-shown works correctly. The error styling only kicks in after the user has typed something.

How do I do password confirmation with Tailwind?

Tailwind can't do cross-field validation — that's HTML's territory. Use 5 lines of JavaScript with setCustomValidity on the password_confirm input. Tailwind then styles the resulting invalid state automatically.

Tailwind validation vs React Hook Form — which?

Tailwind for simple forms where native validation suffices. React Hook Form + Zod for complex forms with cross-field rules, async validation, or typed schemas. Both work together — RHF for logic, Tailwind for styling.

Does Tailwind dark mode work with form validation?

Yes. Apply dark: variants on every color class — borders, backgrounds, text, focus rings. The same form ships in both light and dark mode automatically based on user OS preference.

Related guides

Form validation

Bootstrap Form Validation — Native HTML + Bootstrap 5

Form validation

React Form Validation — Working Code (React Hook Form + Zod)

HTML forms

HTML Form Design — Modern Patterns for 2026

Ship the form, not the backend.

Free for 1,000 submissions/month. Email delivery, AI spam filtering, signed webhooks, real dashboard — all on the free plan. No credit card.

Get a free access key →