Tailwind Forms: 16 Examples With Validation and Layouts

Native form controls ignore a surprising amount of CSS, and the @tailwindcss/forms plugin from Tailwind Labs answers that with a reset layer instead of components. Tailwind CSS v4 loads it with an @plugin line in the main stylesheet, while v3 registers it in tailwind.config.js.

Below are 16 Tailwind form examples you can copy, built with plain utilities, followed by a guide to the forms plugin, layouts and validation states.

By Bogdan Sandu ยท Updated October 29, 2026

Every example is built with Tailwind CSS v4.3 utility classes. To try one quickly, add the Play CDN tags below to your page and paste the example's HTML, CSS and JS. The dark variant line makes dark: classes follow a .dark class instead of the system setting. For production, install Tailwind with Vite or the CLI.

<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<style type="text/tailwindcss">
  @custom-variant dark (&:where(.dark, .dark *));
</style>

Sign-in, registration and multi-step forms

Short account forms where the details matter: a password reveal button, live password rules, floating labels and a three-step signup with a real progress bar. Every control is a native element styled with utilities, no forms plugin.

Split sign-in with password reveal

  • md:grid-cols-[5fr_7fr]
  • appearance-none
  • peer-checked:visible
  • group-aria-pressed:
  • focus-visible:outline-*

Type a password and press the eye button.

An arbitrary grid template splits the card into photo and form from md: up, and the photo is simply hidden on phones. The checkbox is a native input with appearance-none, and its tick is an SVG sibling revealed by peer-checked:visible. The script only flips the password field type and aria-pressed, and group-aria-pressed: swaps the eye icons.

View code
<div class="w-full max-w-3xl overflow-hidden rounded-2xl bg-white shadow-lg ring-1 ring-zinc-900/5 md:grid md:grid-cols-[5fr_7fr]">
  <img src="highland-road.webp" width="800" height="600" alt="Narrow road winding between steep green ridges under a grey sky" class="hidden h-full w-full object-cover md:block">
  <div class="p-6 sm:p-10">
    <h2 class="text-2xl font-semibold tracking-tight text-zinc-900">Sign in to Trailbook</h2>
    <p class="mt-1 text-sm text-zinc-600">Your saved routes and offline maps are waiting.</p>
    <form class="tfm-si-form mt-8 space-y-5">
      <div>
        <label for="tfm-si-email" class="block text-sm font-medium text-zinc-800">Email</label>
        <input id="tfm-si-email" type="email" autocomplete="username" required placeholder="you@example.com" class="mt-1.5 block w-full rounded-lg border border-zinc-500 bg-white px-3.5 py-2.5 text-base text-zinc-900 placeholder:text-zinc-500 focus-visible:border-sky-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-sky-700">
      </div>
      <div>
        <div class="flex flex-wrap items-baseline justify-between gap-x-4">
          <label for="tfm-si-pass" class="block text-sm font-medium text-zinc-800">Password</label>
          <a href="#" class="rounded text-sm font-medium text-sky-800 underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700">Forgot password?</a>
        </div>
        <div class="relative mt-1.5">
          <input id="tfm-si-pass" type="password" autocomplete="current-password" required minlength="8" class="block w-full rounded-lg border border-zinc-500 bg-white py-2.5 pr-12 pl-3.5 text-base text-zinc-900 focus-visible:border-sky-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-sky-700">
          <button type="button" id="tfm-si-toggle" aria-pressed="false" aria-label="Show password" class="group absolute inset-y-0 right-0 grid w-12 place-items-center rounded-r-lg text-zinc-600 hover:text-zinc-900 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-sky-700">
            <svg class="size-5 group-aria-pressed:hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z"/><circle cx="12" cy="12" r="3"/></svg>
            <svg class="hidden size-5 group-aria-pressed:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 3l18 18"/><path d="M10.6 6.1A9.8 9.8 0 0 1 12 6c6 0 9.5 6 9.5 6a17 17 0 0 1-2.7 3.4M6.5 7.6C4 9.3 2.5 12 2.5 12s3.5 6 9.5 6a9.4 9.4 0 0 0 4.4-1.1"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2"/></svg>
          </button>
        </div>
      </div>
      <label class="flex items-center gap-3 text-sm text-zinc-700">
        <span class="grid size-5 shrink-0 place-items-center">
          <input type="checkbox" class="peer col-start-1 row-start-1 size-5 appearance-none rounded border border-zinc-500 bg-white checked:border-sky-700 checked:bg-sky-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700">
          <svg class="pointer-events-none invisible col-start-1 row-start-1 size-3.5 text-white peer-checked:visible" viewBox="0 0 14 14" fill="none" aria-hidden="true"><path d="M3 7.5 5.5 10 11 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
        </span>
        Keep me signed in on this device
      </label>
      <button type="submit" class="w-full rounded-lg bg-zinc-900 px-4 py-3 text-base font-semibold text-white hover:bg-zinc-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-900">Sign in</button>
      <p class="tfm-si-status text-sm font-medium text-emerald-800 empty:hidden" role="status"></p>
    </form>
    <p class="mt-8 text-center text-sm text-zinc-600">New to Trailbook? <a href="#" class="rounded font-semibold text-sky-800 underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700">Create an account</a></p>
  </div>
</div>

Registration with live password rules

  • data-ok:
  • group-data-ok:
  • data-on:
  • disabled:
  • accent-*

Type a password and watch the rules tick off.

The script tests the password on every keystroke and only toggles data-ok on each rule and data-on on each meter segment; data-ok:, group-data-ok: and data-on: utilities do all the coloring and icon swapping. The submit button stays disabled until all four rules pass, styled with disabled: utilities, and the terms checkbox is a native control tinted with accent-violet-700.

View code
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-sm ring-1 ring-zinc-900/10 sm:p-8">
  <h2 class="text-xl font-semibold text-zinc-900">Create your Ledgerline account</h2>
  <p class="mt-1 text-sm text-zinc-600">Free for 14 days, no card needed.</p>
  <form class="tfm-rg-form mt-6 space-y-5">
    <div>
      <label for="tfm-rg-name" class="block text-sm font-medium text-zinc-800">Full name</label>
      <input id="tfm-rg-name" type="text" autocomplete="name" required class="mt-1.5 block w-full rounded-lg border border-zinc-500 bg-white px-3.5 py-2.5 text-base text-zinc-900 focus-visible:border-violet-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-violet-700">
    </div>
    <div>
      <label for="tfm-rg-email" class="block text-sm font-medium text-zinc-800">Work email</label>
      <input id="tfm-rg-email" type="email" autocomplete="email" required class="mt-1.5 block w-full rounded-lg border border-zinc-500 bg-white px-3.5 py-2.5 text-base text-zinc-900 focus-visible:border-violet-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-violet-700">
    </div>
    <div>
      <div class="flex items-baseline justify-between">
        <label for="tfm-rg-pass" class="block text-sm font-medium text-zinc-800">Password</label>
        <span id="tfm-rg-strength" class="text-sm font-medium text-zinc-700" aria-live="polite"></span>
      </div>
      <input id="tfm-rg-pass" type="password" autocomplete="new-password" required aria-describedby="tfm-rg-rules" class="mt-1.5 block w-full rounded-lg border border-zinc-500 bg-white px-3.5 py-2.5 text-base text-zinc-900 focus-visible:border-violet-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-violet-700">
      <div class="mt-3 flex gap-1.5" aria-hidden="true">
        <span class="tfm-rg-bar h-1.5 flex-1 rounded-full bg-zinc-200 transition-colors data-on:bg-violet-700"></span>
        <span class="tfm-rg-bar h-1.5 flex-1 rounded-full bg-zinc-200 transition-colors data-on:bg-violet-700"></span>
        <span class="tfm-rg-bar h-1.5 flex-1 rounded-full bg-zinc-200 transition-colors data-on:bg-violet-700"></span>
        <span class="tfm-rg-bar h-1.5 flex-1 rounded-full bg-zinc-200 transition-colors data-on:bg-violet-700"></span>
      </div>
      <ul id="tfm-rg-rules" class="mt-3 grid gap-1.5 text-sm sm:grid-cols-2">
        <li class="tfm-rg-rule group flex items-center gap-2 text-zinc-600 data-ok:text-emerald-800" data-rule="length">
          <svg class="size-4 shrink-0 group-data-ok:hidden" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><circle cx="8" cy="8" r="6"/></svg>
          <svg class="hidden size-4 shrink-0 group-data-ok:block" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m3.5 8.5 3 3 6-7"/></svg>
          10 or more characters<span class="tfm-rg-sr sr-only">, not met</span>
        </li>
        <li class="tfm-rg-rule group flex items-center gap-2 text-zinc-600 data-ok:text-emerald-800" data-rule="mixed">
          <svg class="size-4 shrink-0 group-data-ok:hidden" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><circle cx="8" cy="8" r="6"/></svg>
          <svg class="hidden size-4 shrink-0 group-data-ok:block" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m3.5 8.5 3 3 6-7"/></svg>
          Upper and lower case<span class="tfm-rg-sr sr-only">, not met</span>
        </li>
        <li class="tfm-rg-rule group flex items-center gap-2 text-zinc-600 data-ok:text-emerald-800" data-rule="number">
          <svg class="size-4 shrink-0 group-data-ok:hidden" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><circle cx="8" cy="8" r="6"/></svg>
          <svg class="hidden size-4 shrink-0 group-data-ok:block" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m3.5 8.5 3 3 6-7"/></svg>
          At least one number<span class="tfm-rg-sr sr-only">, not met</span>
        </li>
        <li class="tfm-rg-rule group flex items-center gap-2 text-zinc-600 data-ok:text-emerald-800" data-rule="symbol">
          <svg class="size-4 shrink-0 group-data-ok:hidden" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><circle cx="8" cy="8" r="6"/></svg>
          <svg class="hidden size-4 shrink-0 group-data-ok:block" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m3.5 8.5 3 3 6-7"/></svg>
          At least one symbol<span class="tfm-rg-sr sr-only">, not met</span>
        </li>
      </ul>
    </div>
    <label class="flex items-start gap-3 text-sm text-zinc-700">
      <input type="checkbox" required class="mt-0.5 size-4 shrink-0 accent-violet-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-700">
      <span>I agree to the <a href="#" class="font-medium text-violet-800 underline underline-offset-2">terms of service</a> and the <a href="#" class="font-medium text-violet-800 underline underline-offset-2">privacy policy</a>.</span>
    </label>
    <button type="submit" id="tfm-rg-submit" disabled class="w-full rounded-lg bg-violet-700 px-4 py-3 text-base font-semibold text-white hover:bg-violet-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-700 disabled:cursor-not-allowed disabled:bg-zinc-200 disabled:text-zinc-600">Create account</button>
    <p class="tfm-rg-status text-sm font-medium text-emerald-800 empty:hidden" role="status"></p>
  </form>
</div>

Floating labels without JavaScript

  • peer
  • peer-placeholder-shown:
  • peer-focus:
  • motion-reduce:transition-none
  • rounded-xl

Click into a field and start typing.

Each input carries placeholder=" " and comes before its label, so peer-placeholder-shown: can drop the label into the field while it is empty and peer-focus: lifts it back when the field gets focus. The resting position uses plain utilities (top-2, text-xs), which means a filled field keeps its label small. No script runs except the demo submit message.

View code
<div class="w-full max-w-lg rounded-3xl bg-white p-6 shadow-sm ring-1 ring-stone-900/10 sm:p-8">
  <h2 class="text-xl font-semibold text-stone-900">Book a portfolio review</h2>
  <p class="mt-1 text-sm text-stone-600">Twenty minutes with a senior designer from Moss and Mortar. We reply within two working days.</p>
  <form class="tfm-fl-form mt-6 grid gap-4 sm:grid-cols-2">
    <div class="relative">
      <input id="tfm-fl-name" type="text" autocomplete="name" placeholder=" " required class="peer block w-full rounded-xl border border-stone-500 bg-white px-3.5 pt-6 pb-2 text-base text-stone-900 focus-visible:border-teal-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-700">
      <label for="tfm-fl-name" class="pointer-events-none absolute top-2 left-3.5 text-xs font-medium text-stone-600 transition-all duration-150 peer-placeholder-shown:top-4 peer-placeholder-shown:text-base peer-focus:top-2 peer-focus:text-xs peer-focus:text-teal-800 motion-reduce:transition-none">Your name</label>
    </div>
    <div class="relative">
      <input id="tfm-fl-email" type="email" autocomplete="email" placeholder=" " required class="peer block w-full rounded-xl border border-stone-500 bg-white px-3.5 pt-6 pb-2 text-base text-stone-900 focus-visible:border-teal-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-700">
      <label for="tfm-fl-email" class="pointer-events-none absolute top-2 left-3.5 text-xs font-medium text-stone-600 transition-all duration-150 peer-placeholder-shown:top-4 peer-placeholder-shown:text-base peer-focus:top-2 peer-focus:text-xs peer-focus:text-teal-800 motion-reduce:transition-none">Email</label>
    </div>
    <div class="relative sm:col-span-2">
      <input id="tfm-fl-link" type="url" placeholder=" " class="peer block w-full rounded-xl border border-stone-500 bg-white px-3.5 pt-6 pb-2 text-base text-stone-900 focus-visible:border-teal-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-700">
      <label for="tfm-fl-link" class="pointer-events-none absolute top-2 left-3.5 text-xs font-medium text-stone-600 transition-all duration-150 peer-placeholder-shown:top-4 peer-placeholder-shown:text-base peer-focus:top-2 peer-focus:text-xs peer-focus:text-teal-800 motion-reduce:transition-none">Portfolio link (optional)</label>
    </div>
    <div class="relative sm:col-span-2">
      <textarea id="tfm-fl-msg" rows="4" placeholder=" " required class="peer block w-full resize-y rounded-xl border border-stone-500 bg-white px-3.5 pt-7 pb-2 text-base text-stone-900 focus-visible:border-teal-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-700"></textarea>
      <label for="tfm-fl-msg" class="pointer-events-none absolute top-2 left-3.5 text-xs font-medium text-stone-600 transition-all duration-150 peer-placeholder-shown:top-4 peer-placeholder-shown:text-base peer-focus:top-2 peer-focus:text-xs peer-focus:text-teal-800 motion-reduce:transition-none">What should we look at?</label>
    </div>
    <div class="flex flex-wrap items-center gap-4 sm:col-span-2">
      <button type="submit" class="rounded-xl bg-teal-800 px-6 py-3 text-base font-semibold text-white hover:bg-teal-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-800">Request a slot</button>
      <p class="tfm-fl-status text-sm font-medium text-teal-800 empty:hidden" role="status"></p>
    </div>
  </form>
</div>

Multi-step signup with progress

  • aria-[current=step]:
  • group-data-done:
  • max-sm:sr-only
  • has-checked:
  • role="progressbar"

Fill in the first step and press Continue.

Each step is a fieldset with a legend, and the script only toggles the hidden attribute, aria-current="step" and a data-done flag. group-aria-[current=step]: and group-data-done: color the step markers, max-sm:sr-only hides the step names on phones without hiding them from screen readers, and the plan cards use has-checked: on the label. Next runs reportValidity() on the visible fields before moving on.

View code
<div class="w-full max-w-xl rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-900/10 sm:p-8">
  <ol class="flex items-center gap-3 text-sm font-medium">
    <li class="tfm-ms-mark group flex items-center gap-2 text-slate-600 aria-[current=step]:text-indigo-700 data-done:text-slate-900" aria-current="step">
      <span class="grid size-8 place-items-center rounded-full border-2 border-slate-300 group-aria-[current=step]:border-indigo-600 group-data-done:border-indigo-600 group-data-done:bg-indigo-600 group-data-done:text-white">1</span>
      <span class="max-sm:sr-only">Account</span>
    </li>
    <li class="h-px flex-1 bg-slate-300" aria-hidden="true"></li>
    <li class="tfm-ms-mark group flex items-center gap-2 text-slate-600 aria-[current=step]:text-indigo-700 data-done:text-slate-900">
      <span class="grid size-8 place-items-center rounded-full border-2 border-slate-300 group-aria-[current=step]:border-indigo-600 group-data-done:border-indigo-600 group-data-done:bg-indigo-600 group-data-done:text-white">2</span>
      <span class="max-sm:sr-only">Workspace</span>
    </li>
    <li class="h-px flex-1 bg-slate-300" aria-hidden="true"></li>
    <li class="tfm-ms-mark group flex items-center gap-2 text-slate-600 aria-[current=step]:text-indigo-700 data-done:text-slate-900">
      <span class="grid size-8 place-items-center rounded-full border-2 border-slate-300 group-aria-[current=step]:border-indigo-600 group-data-done:border-indigo-600 group-data-done:bg-indigo-600 group-data-done:text-white">3</span>
      <span class="max-sm:sr-only">Plan</span>
    </li>
  </ol>
  <div class="mt-5 flex items-center justify-between text-sm">
    <p id="tfm-ms-count" class="font-medium text-slate-700">Step 1 of 3</p>
    <p class="text-slate-600">About 2 minutes</p>
  </div>
  <div id="tfm-ms-bar" class="mt-2 h-1.5 overflow-hidden rounded-full bg-slate-200" role="progressbar" aria-label="Signup progress" aria-valuemin="1" aria-valuemax="3" aria-valuenow="1">
    <div class="tfm-ms-fill h-full w-1/3 rounded-full bg-indigo-600 transition-[width] duration-300 motion-reduce:transition-none"></div>
  </div>
  <form class="tfm-ms-form mt-6">
    <fieldset class="tfm-ms-step grid gap-4">
      <legend class="mb-4 text-lg font-semibold text-slate-900">Tell us who you are</legend>
      <div>
        <label for="tfm-ms-name" class="block text-sm font-medium text-slate-800">Full name</label>
        <input id="tfm-ms-name" type="text" autocomplete="name" required class="mt-1.5 block w-full rounded-lg border border-slate-500 bg-white px-3.5 py-2.5 text-base text-slate-900 focus-visible:border-indigo-600 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-indigo-600">
      </div>
      <div>
        <label for="tfm-ms-email" class="block text-sm font-medium text-slate-800">Work email</label>
        <input id="tfm-ms-email" type="email" autocomplete="email" required class="mt-1.5 block w-full rounded-lg border border-slate-500 bg-white px-3.5 py-2.5 text-base text-slate-900 focus-visible:border-indigo-600 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-indigo-600">
      </div>
    </fieldset>
    <fieldset class="tfm-ms-step grid gap-4" hidden>
      <legend class="mb-4 text-lg font-semibold text-slate-900">Set up your workspace</legend>
      <div>
        <label for="tfm-ms-ws" class="block text-sm font-medium text-slate-800">Workspace name</label>
        <input id="tfm-ms-ws" type="text" required class="mt-1.5 block w-full rounded-lg border border-slate-500 bg-white px-3.5 py-2.5 text-base text-slate-900 focus-visible:border-indigo-600 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-indigo-600">
      </div>
      <div>
        <label for="tfm-ms-size" class="block text-sm font-medium text-slate-800">Team size</label>
        <div class="mt-1.5 grid">
          <select id="tfm-ms-size" class="col-start-1 row-start-1 w-full appearance-none rounded-lg border border-slate-500 bg-white py-2.5 pr-10 pl-3.5 text-base text-slate-900 focus-visible:border-indigo-600 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-indigo-600">
            <option>Just me</option>
            <option selected>2 to 10 people</option>
            <option>11 to 50 people</option>
            <option>More than 50</option>
          </select>
          <svg class="pointer-events-none col-start-1 row-start-1 mr-3 size-5 self-center justify-self-end text-slate-600" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 8 4 4 4-4"/></svg>
        </div>
      </div>
    </fieldset>
    <fieldset class="tfm-ms-step grid gap-3" hidden>
      <legend class="mb-4 text-lg font-semibold text-slate-900">Pick a plan</legend>
      <label class="flex cursor-pointer items-start gap-3 rounded-xl border border-slate-300 p-4 has-checked:border-indigo-600 has-checked:bg-indigo-50 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-indigo-600">
        <input type="radio" name="tfm-ms-plan" value="Starter" class="mt-1 size-4 shrink-0 accent-indigo-600 focus-visible:outline-none">
        <span><span class="block font-semibold text-slate-900">Starter, free</span><span class="block text-sm text-slate-600">3 projects and 1 GB of files.</span></span>
      </label>
      <label class="flex cursor-pointer items-start gap-3 rounded-xl border border-slate-300 p-4 has-checked:border-indigo-600 has-checked:bg-indigo-50 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-indigo-600">
        <input type="radio" name="tfm-ms-plan" value="Team" checked class="mt-1 size-4 shrink-0 accent-indigo-600 focus-visible:outline-none">
        <span><span class="block font-semibold text-slate-900">Team, $12 per seat</span><span class="block text-sm text-slate-600">Unlimited projects, guest access and version history.</span></span>
      </label>
    </fieldset>
    <div class="mt-8 flex items-center gap-3">
      <button type="button" id="tfm-ms-back" hidden class="rounded-lg px-4 py-3 text-base font-semibold text-slate-700 hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Back</button>
      <button type="button" id="tfm-ms-next" class="ml-auto rounded-lg bg-indigo-600 px-5 py-3 text-base font-semibold text-white hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Continue</button>
      <button type="submit" id="tfm-ms-submit" hidden class="ml-auto rounded-lg bg-indigo-600 px-5 py-3 text-base font-semibold text-white hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Create workspace</button>
    </div>
    <p id="tfm-ms-status" class="mt-4 text-sm font-medium text-emerald-800 empty:hidden" role="status"></p>
  </form>
</div>

Contact, checkout, settings and booking layouts

Longer forms built on grid utilities: two-column contact and address grids, settings split into labelled sections, a booking form with picture cards and a card whose action bar stays pinned while the fields scroll.

Contact form with info panel

  • md:grid-cols-[2fr_3fr]
  • sm:grid-cols-2
  • sm:col-span-2
  • appearance-none
  • resize-y

The outer card is an arbitrary two-track grid from md: up, and the form itself is the one-column, two-column pattern from the guide: grid-cols-1 with sm:grid-cols-2, and sm:col-span-2 on the wide fields. The budget select drops its native arrow with appearance-none and stacks an SVG chevron in the same grid cell. The script only updates the character counter.

View code
<div class="w-full max-w-4xl overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-slate-900/10 md:grid md:grid-cols-[2fr_3fr]">
  <div class="bg-slate-900 p-6 text-slate-300 sm:p-8">
    <h2 class="text-2xl font-semibold text-white">Start a project</h2>
    <p class="mt-3 text-sm/6">Harbor and Pine builds brand sites for small makers. Tell us what you have in mind and we will reply with a short plan and a rough quote.</p>
    <dl class="mt-8 space-y-4 text-sm">
      <div class="flex gap-3">
        <dt><span class="sr-only">Email</span><svg class="size-5 text-amber-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3.5 6.5 8.5 6 8.5-6"/></svg></dt>
        <dd>studio@example.com</dd>
      </div>
      <div class="flex gap-3">
        <dt><span class="sr-only">Phone</span><svg class="size-5 text-amber-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a1 1 0 0 1-1 1A16 16 0 0 1 4 5a1 1 0 0 1 1-1Z"/></svg></dt>
        <dd>(555) 014-2290</dd>
      </div>
      <div class="flex gap-3">
        <dt><span class="sr-only">Studio</span><svg class="size-5 text-amber-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 21s-7-6.2-7-11.5a7 7 0 0 1 14 0C19 14.8 12 21 12 21Z"/><circle cx="12" cy="9.5" r="2.5"/></svg></dt>
        <dd>18 Ropewalk Lane, Portland</dd>
      </div>
    </dl>
  </div>
  <form class="tfm-ct-form grid grid-cols-1 gap-5 p-6 sm:grid-cols-2 sm:p-8">
    <div>
      <label for="tfm-ct-first" class="block text-sm font-medium text-slate-800">First name</label>
      <input id="tfm-ct-first" type="text" autocomplete="given-name" required class="mt-1.5 block w-full rounded-lg border border-slate-500 bg-white px-3.5 py-2.5 text-base text-slate-900 focus-visible:border-slate-900 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-slate-900">
    </div>
    <div>
      <label for="tfm-ct-last" class="block text-sm font-medium text-slate-800">Last name</label>
      <input id="tfm-ct-last" type="text" autocomplete="family-name" required class="mt-1.5 block w-full rounded-lg border border-slate-500 bg-white px-3.5 py-2.5 text-base text-slate-900 focus-visible:border-slate-900 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-slate-900">
    </div>
    <div class="sm:col-span-2">
      <label for="tfm-ct-email" class="block text-sm font-medium text-slate-800">Email</label>
      <input id="tfm-ct-email" type="email" autocomplete="email" required class="mt-1.5 block w-full rounded-lg border border-slate-500 bg-white px-3.5 py-2.5 text-base text-slate-900 focus-visible:border-slate-900 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-slate-900">
    </div>
    <div class="sm:col-span-2">
      <label for="tfm-ct-budget" class="block text-sm font-medium text-slate-800">Budget</label>
      <div class="mt-1.5 grid">
        <select id="tfm-ct-budget" class="col-start-1 row-start-1 w-full appearance-none rounded-lg border border-slate-500 bg-white py-2.5 pr-10 pl-3.5 text-base text-slate-900 focus-visible:border-slate-900 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-slate-900">
          <option>Under $5,000</option>
          <option selected>$5,000 to $15,000</option>
          <option>$15,000 to $40,000</option>
          <option>Not sure yet</option>
        </select>
        <svg class="pointer-events-none col-start-1 row-start-1 mr-3 size-5 self-center justify-self-end text-slate-600" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 8 4 4 4-4"/></svg>
      </div>
    </div>
    <div class="sm:col-span-2">
      <label for="tfm-ct-msg" class="block text-sm font-medium text-slate-800">About the project</label>
      <textarea id="tfm-ct-msg" rows="4" maxlength="600" required aria-describedby="tfm-ct-count" class="mt-1.5 block w-full resize-y rounded-lg border border-slate-500 bg-white px-3.5 py-2.5 text-base text-slate-900 focus-visible:border-slate-900 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-slate-900"></textarea>
      <p id="tfm-ct-count" class="mt-1.5 text-right text-sm text-slate-600">0 of 600 characters</p>
    </div>
    <div class="flex flex-wrap items-center gap-4 sm:col-span-2">
      <button type="submit" class="rounded-lg bg-amber-400 px-6 py-3 text-base font-semibold text-slate-950 hover:bg-amber-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-900">Send message</button>
      <p class="tfm-ct-status text-sm font-medium text-slate-700 empty:hidden" role="status"></p>
    </div>
  </form>
</div>

Checkout with address grid

  • grid-cols-6
  • sm:col-span-3
  • sm:col-span-2
  • has-checked:ring-2
  • tabular-nums

Switch the delivery option to see the total change.

The address block is a six-column grid: full-width rows use col-span-6, name fields take sm:col-span-3 and city, region and postal code take sm:col-span-2 each, so the row math always adds up to six. Delivery options are labels with has-checked: borders and rings around native radios, and the order summary sits inside the same form so the pay button submits everything. The script only recalculates shipping and the total.

View code
<form class="tfm-co-form w-full max-w-4xl md:grid md:grid-cols-[3fr_2fr] md:gap-6">
  <div class="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-900/10 sm:p-8">
    <h2 class="text-lg font-semibold text-slate-900">Shipping address</h2>
    <div class="mt-5 grid grid-cols-6 gap-x-4 gap-y-5">
      <div class="col-span-6">
        <label for="tfm-co-email" class="block text-sm font-medium text-slate-800">Email for receipt</label>
        <input id="tfm-co-email" type="email" autocomplete="email" required class="mt-1.5 block w-full rounded-lg border border-slate-500 bg-white px-3.5 py-2.5 text-base text-slate-900 focus-visible:border-blue-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-blue-700">
      </div>
      <div class="col-span-6 sm:col-span-3">
        <label for="tfm-co-first" class="block text-sm font-medium text-slate-800">First name</label>
        <input id="tfm-co-first" type="text" autocomplete="shipping given-name" required class="mt-1.5 block w-full rounded-lg border border-slate-500 bg-white px-3.5 py-2.5 text-base text-slate-900 focus-visible:border-blue-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-blue-700">
      </div>
      <div class="col-span-6 sm:col-span-3">
        <label for="tfm-co-last" class="block text-sm font-medium text-slate-800">Last name</label>
        <input id="tfm-co-last" type="text" autocomplete="shipping family-name" required class="mt-1.5 block w-full rounded-lg border border-slate-500 bg-white px-3.5 py-2.5 text-base text-slate-900 focus-visible:border-blue-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-blue-700">
      </div>
      <div class="col-span-6">
        <label for="tfm-co-street" class="block text-sm font-medium text-slate-800">Street address</label>
        <input id="tfm-co-street" type="text" autocomplete="shipping address-line1" required class="mt-1.5 block w-full rounded-lg border border-slate-500 bg-white px-3.5 py-2.5 text-base text-slate-900 focus-visible:border-blue-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-blue-700">
      </div>
      <div class="col-span-6 sm:col-span-2">
        <label for="tfm-co-city" class="block text-sm font-medium text-slate-800">City</label>
        <input id="tfm-co-city" type="text" autocomplete="shipping address-level2" required class="mt-1.5 block w-full rounded-lg border border-slate-500 bg-white px-3.5 py-2.5 text-base text-slate-900 focus-visible:border-blue-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-blue-700">
      </div>
      <div class="col-span-3 sm:col-span-2">
        <label for="tfm-co-region" class="block text-sm font-medium text-slate-800">State</label>
        <input id="tfm-co-region" type="text" autocomplete="shipping address-level1" required class="mt-1.5 block w-full rounded-lg border border-slate-500 bg-white px-3.5 py-2.5 text-base text-slate-900 focus-visible:border-blue-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-blue-700">
      </div>
      <div class="col-span-3 sm:col-span-2">
        <label for="tfm-co-zip" class="block text-sm font-medium text-slate-800">ZIP code</label>
        <input id="tfm-co-zip" type="text" inputmode="numeric" autocomplete="shipping postal-code" required class="mt-1.5 block w-full rounded-lg border border-slate-500 bg-white px-3.5 py-2.5 text-base text-slate-900 focus-visible:border-blue-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-blue-700">
      </div>
    </div>
    <fieldset class="mt-8">
      <legend class="text-lg font-semibold text-slate-900">Delivery</legend>
      <div class="mt-4 grid gap-3 sm:grid-cols-2">
        <label class="flex cursor-pointer gap-3 rounded-xl border border-slate-300 p-4 has-checked:border-blue-700 has-checked:ring-2 has-checked:ring-blue-700">
          <input type="radio" name="tfm-co-ship" value="0" checked class="mt-1 size-4 shrink-0 accent-blue-700">
          <span><span class="block font-semibold text-slate-900">Standard</span><span class="block text-sm text-slate-600">4 to 6 business days</span><span class="mt-2 block text-sm font-semibold text-slate-900">Free</span></span>
        </label>
        <label class="flex cursor-pointer gap-3 rounded-xl border border-slate-300 p-4 has-checked:border-blue-700 has-checked:ring-2 has-checked:ring-blue-700">
          <input type="radio" name="tfm-co-ship" value="12" id="tfm-co-express" class="mt-1 size-4 shrink-0 accent-blue-700">
          <span><span class="block font-semibold text-slate-900">Express</span><span class="block text-sm text-slate-600">1 to 2 business days</span><span class="mt-2 block text-sm font-semibold text-slate-900">$12.00</span></span>
        </label>
      </div>
    </fieldset>
  </div>
  <aside class="mt-6 h-fit rounded-2xl bg-slate-100 p-6 md:mt-0" aria-labelledby="tfm-co-summary">
    <h2 id="tfm-co-summary" class="text-lg font-semibold text-slate-900">Order summary</h2>
    <ul class="mt-4 divide-y divide-slate-300 text-sm">
      <li class="flex justify-between gap-4 py-3"><span class="text-slate-700">Waxed canvas tote</span><span class="font-medium text-slate-900 tabular-nums">$68.00</span></li>
      <li class="flex justify-between gap-4 py-3"><span class="text-slate-700">Brass key ring</span><span class="font-medium text-slate-900 tabular-nums">$14.00</span></li>
    </ul>
    <dl class="mt-2 space-y-2 border-t border-slate-300 pt-4 text-sm">
      <div class="flex justify-between"><dt class="text-slate-700">Subtotal</dt><dd class="text-slate-900 tabular-nums">$82.00</dd></div>
      <div class="flex justify-between"><dt class="text-slate-700">Shipping</dt><dd id="tfm-co-ship-cost" class="text-slate-900 tabular-nums">Free</dd></div>
      <div class="flex justify-between"><dt class="text-slate-700">Estimated tax</dt><dd class="text-slate-900 tabular-nums">$6.56</dd></div>
      <div class="flex justify-between border-t border-slate-300 pt-3 text-base font-semibold"><dt class="text-slate-900">Total</dt><dd id="tfm-co-total" class="text-slate-900 tabular-nums" aria-live="polite">$88.56</dd></div>
    </dl>
    <button type="submit" class="mt-6 w-full rounded-lg bg-blue-700 px-4 py-3 text-base font-semibold text-white hover:bg-blue-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-700">Pay now</button>
    <p class="tfm-co-status mt-3 text-sm font-medium text-emerald-800 empty:hidden" role="status"></p>
  </aside>
</form>

Settings page with labelled sections

  • md:grid-cols-3
  • divide-y
  • has-focus-visible:
  • accent-*
  • disabled:opacity-50

Change any field to enable Save.

Each section is a grid that puts the heading and description in a narrow left column from md: up, and divide-y draws the rules between sections without extra markup. The username field is an input inside a flex row whose border reacts through has-focus-visible:, and the notification checkboxes are native controls with accent-emerald-700. The script enables Save only after something changes.

View code
<form class="tfm-st-form mx-auto max-w-4xl divide-y divide-gray-200 bg-white px-5 py-8 sm:px-8">
  <section class="grid gap-6 pb-8 md:grid-cols-3" aria-labelledby="tfm-st-profile">
    <div>
      <h2 id="tfm-st-profile" class="text-base font-semibold text-gray-900">Profile</h2>
      <p class="mt-1 text-sm text-gray-600">Shown on your public portfolio and in comments.</p>
    </div>
    <div class="grid gap-5 md:col-span-2">
      <div class="flex items-center gap-4">
        <span class="grid size-14 shrink-0 place-items-center rounded-full bg-emerald-100 text-lg font-semibold text-emerald-900" aria-hidden="true">LM</span>
        <button type="button" class="rounded-lg border border-gray-400 px-3.5 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-700">Change photo</button>
      </div>
      <div>
        <label for="tfm-st-name" class="block text-sm font-medium text-gray-800">Display name</label>
        <input id="tfm-st-name" type="text" value="Lina Moreau" autocomplete="name" class="mt-1.5 block w-full rounded-lg border border-gray-500 bg-white px-3.5 py-2.5 text-base text-gray-900 focus-visible:border-emerald-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-emerald-700">
      </div>
      <div>
        <label for="tfm-st-user" class="block text-sm font-medium text-gray-800">Username</label>
        <div class="mt-1.5 flex rounded-lg border border-gray-500 bg-white has-focus-visible:border-emerald-700 has-focus-visible:outline-2 has-focus-visible:-outline-offset-1 has-focus-visible:outline-emerald-700">
          <span class="flex items-center pl-3.5 text-base text-gray-600 select-none">folio.example/</span>
          <input id="tfm-st-user" type="text" value="lina" autocomplete="username" class="min-w-0 flex-1 rounded-r-lg bg-transparent py-2.5 pr-3.5 pl-0.5 text-base text-gray-900 focus-visible:outline-none">
        </div>
      </div>
      <div>
        <label for="tfm-st-bio" class="block text-sm font-medium text-gray-800">Bio</label>
        <textarea id="tfm-st-bio" rows="3" aria-describedby="tfm-st-bio-help" class="mt-1.5 block w-full resize-y rounded-lg border border-gray-500 bg-white px-3.5 py-2.5 text-base text-gray-900 focus-visible:border-emerald-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-emerald-700">Type designer in Lyon. Currently drawing a grotesque with very short descenders.</textarea>
        <p id="tfm-st-bio-help" class="mt-1.5 text-sm text-gray-600">A sentence or two. Links are not clickable here.</p>
      </div>
    </div>
  </section>
  <section class="grid gap-6 py-8 md:grid-cols-3" aria-labelledby="tfm-st-notify">
    <div>
      <h2 id="tfm-st-notify" class="text-base font-semibold text-gray-900">Notifications</h2>
      <p class="mt-1 text-sm text-gray-600">We only email about activity on your own work.</p>
    </div>
    <div class="grid gap-6 md:col-span-2">
      <fieldset>
        <legend class="text-sm font-semibold text-gray-900">Email me when</legend>
        <div class="mt-3 space-y-4">
          <div class="flex gap-3">
            <input id="tfm-st-n1" type="checkbox" checked aria-describedby="tfm-st-n1-d" class="mt-0.5 size-4 shrink-0 accent-emerald-700">
            <div class="text-sm"><label for="tfm-st-n1" class="font-medium text-gray-900">Someone comments on a project</label><p id="tfm-st-n1-d" class="text-gray-600">Includes replies to your comments.</p></div>
          </div>
          <div class="flex gap-3">
            <input id="tfm-st-n2" type="checkbox" aria-describedby="tfm-st-n2-d" class="mt-0.5 size-4 shrink-0 accent-emerald-700">
            <div class="text-sm"><label for="tfm-st-n2" class="font-medium text-gray-900">A project gets featured</label><p id="tfm-st-n2-d" class="text-gray-600">Sent once, the day it goes live.</p></div>
          </div>
        </div>
      </fieldset>
      <fieldset>
        <legend class="text-sm font-semibold text-gray-900">Weekly digest</legend>
        <div class="mt-3 flex flex-wrap gap-x-6 gap-y-3 text-sm text-gray-900">
          <label class="flex items-center gap-2"><input type="radio" name="tfm-st-digest" checked class="size-4 accent-emerald-700">Monday</label>
          <label class="flex items-center gap-2"><input type="radio" name="tfm-st-digest" class="size-4 accent-emerald-700">Friday</label>
          <label class="flex items-center gap-2"><input type="radio" name="tfm-st-digest" class="size-4 accent-emerald-700">Never</label>
        </div>
      </fieldset>
    </div>
  </section>
  <section class="grid gap-6 py-8 md:grid-cols-3" aria-labelledby="tfm-st-region">
    <div>
      <h2 id="tfm-st-region" class="text-base font-semibold text-gray-900">Region</h2>
      <p class="mt-1 text-sm text-gray-600">Controls dates and times in your dashboard.</p>
    </div>
    <div class="grid gap-5 sm:grid-cols-2 md:col-span-2">
      <div>
        <label for="tfm-st-lang" class="block text-sm font-medium text-gray-800">Language</label>
        <div class="mt-1.5 grid">
          <select id="tfm-st-lang" class="col-start-1 row-start-1 w-full appearance-none rounded-lg border border-gray-500 bg-white py-2.5 pr-10 pl-3.5 text-base text-gray-900 focus-visible:border-emerald-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-emerald-700">
            <option>English</option>
            <option selected>French</option>
            <option>German</option>
          </select>
          <svg class="pointer-events-none col-start-1 row-start-1 mr-3 size-5 self-center justify-self-end text-gray-600" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 8 4 4 4-4"/></svg>
        </div>
      </div>
      <div>
        <label for="tfm-st-tz" class="block text-sm font-medium text-gray-800">Time zone</label>
        <div class="mt-1.5 grid">
          <select id="tfm-st-tz" class="col-start-1 row-start-1 w-full appearance-none rounded-lg border border-gray-500 bg-white py-2.5 pr-10 pl-3.5 text-base text-gray-900 focus-visible:border-emerald-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-emerald-700">
            <option>London (GMT)</option>
            <option selected>Paris (CET)</option>
            <option>New York (EST)</option>
          </select>
          <svg class="pointer-events-none col-start-1 row-start-1 mr-3 size-5 self-center justify-self-end text-gray-600" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 8 4 4 4-4"/></svg>
        </div>
      </div>
    </div>
  </section>
  <div class="flex flex-wrap items-center justify-end gap-3 pt-6">
    <p class="tfm-st-status mr-auto text-sm font-medium text-emerald-800 empty:hidden" role="status"></p>
    <button type="reset" class="rounded-lg px-4 py-2.5 text-sm font-semibold text-gray-800 hover:bg-gray-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-700">Cancel</button>
    <button type="submit" id="tfm-st-save" disabled class="rounded-lg bg-emerald-700 px-5 py-2.5 text-sm font-semibold text-white hover:bg-emerald-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-700 disabled:cursor-not-allowed disabled:opacity-50">Save changes</button>
  </div>
</form>

Cabin booking with picture cards

  • has-checked:
  • group-has-checked:
  • [appearance:textfield]
  • [&::-webkit-inner-spin-button]:appearance-none
  • scheme-light

Pick a cabin or change the guests and dates.

The cabin cards are labels around visually hidden radios, so has-checked: draws the selected ring and group-has-checked: shows the tick. The guest count is a number input whose spin buttons are removed with [appearance:textfield] and the [&::-webkit-inner-spin-button]:appearance-none arbitrary variant, with real minus and plus buttons beside it. The script fills dates two weeks out and recalculates the price.

View code
<form class="tfm-bk-form w-full max-w-3xl rounded-3xl bg-white p-6 shadow-sm ring-1 ring-stone-900/10 sm:p-8">
  <h2 class="text-xl font-semibold text-stone-900">Book a cabin at Kettle Lake</h2>
  <p class="mt-1 text-sm text-stone-600">Two-night minimum on weekends. Firewood included.</p>
  <fieldset class="mt-6">
    <legend class="text-sm font-semibold text-stone-900">Cabin</legend>
    <div class="mt-3 grid gap-3 sm:grid-cols-3">
      <label class="group relative flex cursor-pointer items-center gap-3 overflow-hidden rounded-2xl border border-stone-300 bg-white p-2 has-checked:border-orange-800 has-checked:ring-2 has-checked:ring-orange-800 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-orange-800 sm:block sm:p-0">
        <input type="radio" name="tfm-bk-cabin" value="180" data-name="Fjord cabin" checked class="sr-only">
        <img src="fjord.webp" width="800" height="600" alt="" class="size-20 shrink-0 rounded-xl object-cover sm:aspect-[4/3] sm:h-auto sm:w-full sm:rounded-none">
        <span class="block sm:p-3"><span class="block font-semibold text-stone-900">Fjord cabin</span><span class="block text-sm text-stone-600">Cliff view, sleeps 4</span><span class="mt-1 block text-sm font-semibold text-stone-900">$180 a night</span></span>
        <svg class="absolute top-2 right-2 hidden size-6 rounded-full bg-orange-800 p-1 text-white group-has-checked:block" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m3.5 8.5 3 3 6-7"/></svg>
      </label>
      <label class="group relative flex cursor-pointer items-center gap-3 overflow-hidden rounded-2xl border border-stone-300 bg-white p-2 has-checked:border-orange-800 has-checked:ring-2 has-checked:ring-orange-800 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-orange-800 sm:block sm:p-0">
        <input type="radio" name="tfm-bk-cabin" value="145" data-name="Falls cabin" class="sr-only">
        <img src="forest-waterfall.webp" width="800" height="600" alt="" class="size-20 shrink-0 rounded-xl object-cover sm:aspect-[4/3] sm:h-auto sm:w-full sm:rounded-none">
        <span class="block sm:p-3"><span class="block font-semibold text-stone-900">Falls cabin</span><span class="block text-sm text-stone-600">Forest trail, sleeps 2</span><span class="mt-1 block text-sm font-semibold text-stone-900">$145 a night</span></span>
        <svg class="absolute top-2 right-2 hidden size-6 rounded-full bg-orange-800 p-1 text-white group-has-checked:block" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m3.5 8.5 3 3 6-7"/></svg>
      </label>
      <label class="group relative flex cursor-pointer items-center gap-3 overflow-hidden rounded-2xl border border-stone-300 bg-white p-2 has-checked:border-orange-800 has-checked:ring-2 has-checked:ring-orange-800 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-orange-800 sm:block sm:p-0">
        <input type="radio" name="tfm-bk-cabin" value="210" data-name="Valley lodge" id="tfm-bk-valley" class="sr-only">
        <img src="granite-valley.webp" width="800" height="600" alt="" class="size-20 shrink-0 rounded-xl object-cover sm:aspect-[4/3] sm:h-auto sm:w-full sm:rounded-none">
        <span class="block sm:p-3"><span class="block font-semibold text-stone-900">Valley lodge</span><span class="block text-sm text-stone-600">River beach, sleeps 6</span><span class="mt-1 block text-sm font-semibold text-stone-900">$210 a night</span></span>
        <svg class="absolute top-2 right-2 hidden size-6 rounded-full bg-orange-800 p-1 text-white group-has-checked:block" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m3.5 8.5 3 3 6-7"/></svg>
      </label>
    </div>
  </fieldset>
  <div class="mt-6 grid grid-cols-2 gap-4 sm:grid-cols-3">
    <div>
      <label for="tfm-bk-in" class="block text-sm font-medium text-stone-800">Check-in</label>
      <input id="tfm-bk-in" type="date" required class="mt-1.5 block w-full rounded-lg border border-stone-500 bg-white px-3 py-2.5 text-base text-stone-900 scheme-light focus-visible:border-orange-800 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-orange-800">
    </div>
    <div>
      <label for="tfm-bk-out" class="block text-sm font-medium text-stone-800">Check-out</label>
      <input id="tfm-bk-out" type="date" required class="mt-1.5 block w-full rounded-lg border border-stone-500 bg-white px-3 py-2.5 text-base text-stone-900 scheme-light focus-visible:border-orange-800 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-orange-800">
    </div>
    <div class="col-span-2 sm:col-span-1">
      <label for="tfm-bk-guests" class="block text-sm font-medium text-stone-800">Guests</label>
      <div class="mt-1.5 flex rounded-lg border border-stone-500 has-focus-visible:outline-2 has-focus-visible:-outline-offset-1 has-focus-visible:outline-orange-800">
        <button type="button" id="tfm-bk-minus" aria-label="Fewer guests" class="grid w-11 place-items-center rounded-l-lg text-stone-700 hover:bg-stone-100 focus-visible:outline-none">
          <svg class="size-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M3 8h10"/></svg>
        </button>
        <input id="tfm-bk-guests" type="number" min="1" max="6" value="2" class="min-w-0 flex-1 bg-transparent py-2.5 text-center text-base text-stone-900 [appearance:textfield] focus-visible:outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none">
        <button type="button" id="tfm-bk-plus" aria-label="More guests" class="grid w-11 place-items-center rounded-r-lg text-stone-700 hover:bg-stone-100 focus-visible:outline-none">
          <svg class="size-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M3 8h10M8 3v10"/></svg>
        </button>
      </div>
    </div>
  </div>
  <div class="mt-6 flex flex-col gap-4 border-t border-stone-200 pt-6 sm:flex-row sm:items-center sm:justify-between">
    <p id="tfm-bk-total" class="text-base text-stone-700" aria-live="polite"></p>
    <button type="submit" class="rounded-xl bg-orange-800 px-6 py-3 text-base font-semibold text-white hover:bg-orange-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-800">Reserve</button>
  </div>
</form>

Card form with sticky action bar

  • sticky bottom-0
  • max-h-[440px]
  • overflow-y-auto
  • group-data-dirty:
  • shadow-[...]

Scroll the card and edit any field.

The form body scrolls inside max-h-[440px] overflow-y-auto, and the footer inside it uses sticky bottom-0 with a solid background and an arbitrary upward shadow, so Save stays reachable however long the form grows. Typing sets a data-dirty attribute on the form, and group-data-dirty: swaps the status text and shows the amber dot.

View code
<div class="w-full max-w-xl overflow-hidden rounded-2xl bg-white shadow-md ring-1 ring-neutral-900/10">
  <div class="flex items-center justify-between gap-4 border-b border-neutral-200 px-6 py-4">
    <h2 id="tfm-sk-title" class="text-lg font-semibold text-neutral-900">Edit listing</h2>
    <span class="rounded-full bg-fuchsia-100 px-2.5 py-1 text-xs font-semibold text-fuchsia-900">Draft</span>
  </div>
  <form class="tfm-sk-form group max-h-[440px] overflow-y-auto" aria-labelledby="tfm-sk-title">
    <div class="grid gap-5 px-6 py-6 sm:grid-cols-2">
      <div class="sm:col-span-2">
        <label for="tfm-sk-name" class="block text-sm font-medium text-neutral-800">Product name</label>
        <input id="tfm-sk-name" type="text" value="Riso print, Night Ferry" class="mt-1.5 block w-full rounded-lg border border-neutral-500 bg-white px-3.5 py-2.5 text-base text-neutral-900 focus-visible:border-fuchsia-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-fuchsia-700">
      </div>
      <div class="sm:col-span-2">
        <label for="tfm-sk-desc" class="block text-sm font-medium text-neutral-800">Description</label>
        <textarea id="tfm-sk-desc" rows="4" class="mt-1.5 block w-full resize-y rounded-lg border border-neutral-500 bg-white px-3.5 py-2.5 text-base text-neutral-900 focus-visible:border-fuchsia-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-fuchsia-700">Two-color risograph print in fluorescent pink and federal blue on 120 gsm recycled paper. Signed and numbered, edition of 80.</textarea>
      </div>
      <div>
        <label for="tfm-sk-price" class="block text-sm font-medium text-neutral-800">Price</label>
        <div class="mt-1.5 flex rounded-lg border border-neutral-500 bg-white has-focus-visible:border-fuchsia-700 has-focus-visible:outline-2 has-focus-visible:-outline-offset-1 has-focus-visible:outline-fuchsia-700">
          <span class="flex items-center pl-3.5 text-base text-neutral-600" aria-hidden="true">$</span>
          <input id="tfm-sk-price" type="text" inputmode="decimal" value="38.00" class="min-w-0 flex-1 rounded-r-lg bg-transparent py-2.5 pr-3.5 pl-1.5 text-base text-neutral-900 focus-visible:outline-none">
        </div>
      </div>
      <div>
        <label for="tfm-sk-stock" class="block text-sm font-medium text-neutral-800">Stock</label>
        <input id="tfm-sk-stock" type="number" min="0" value="62" class="mt-1.5 block w-full rounded-lg border border-neutral-500 bg-white px-3.5 py-2.5 text-base text-neutral-900 focus-visible:border-fuchsia-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-fuchsia-700">
      </div>
      <fieldset class="sm:col-span-2">
        <legend class="text-sm font-medium text-neutral-800">Collections</legend>
        <div class="mt-2 flex flex-wrap gap-2">
          <label class="cursor-pointer rounded-full border border-neutral-400 px-3.5 py-1.5 text-sm text-neutral-800 has-checked:border-fuchsia-800 has-checked:bg-fuchsia-800 has-checked:text-white has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-fuchsia-700"><input type="checkbox" checked class="sr-only">Prints</label>
          <label class="cursor-pointer rounded-full border border-neutral-400 px-3.5 py-1.5 text-sm text-neutral-800 has-checked:border-fuchsia-800 has-checked:bg-fuchsia-800 has-checked:text-white has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-fuchsia-700"><input type="checkbox" class="sr-only">New this month</label>
          <label class="cursor-pointer rounded-full border border-neutral-400 px-3.5 py-1.5 text-sm text-neutral-800 has-checked:border-fuchsia-800 has-checked:bg-fuchsia-800 has-checked:text-white has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-fuchsia-700"><input type="checkbox" checked class="sr-only">Limited editions</label>
          <label class="cursor-pointer rounded-full border border-neutral-400 px-3.5 py-1.5 text-sm text-neutral-800 has-checked:border-fuchsia-800 has-checked:bg-fuchsia-800 has-checked:text-white has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-fuchsia-700"><input type="checkbox" class="sr-only">Gifts under $50</label>
        </div>
      </fieldset>
      <div class="sm:col-span-2">
        <label for="tfm-sk-seo" class="block text-sm font-medium text-neutral-800">Search title</label>
        <input id="tfm-sk-seo" type="text" value="Night Ferry riso print, signed edition of 80" class="mt-1.5 block w-full rounded-lg border border-neutral-500 bg-white px-3.5 py-2.5 text-base text-neutral-900 focus-visible:border-fuchsia-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-fuchsia-700">
      </div>
      <div class="sm:col-span-2">
        <label for="tfm-sk-meta" class="block text-sm font-medium text-neutral-800">Search description</label>
        <textarea id="tfm-sk-meta" rows="3" class="mt-1.5 block w-full resize-y rounded-lg border border-neutral-500 bg-white px-3.5 py-2.5 text-base text-neutral-900 focus-visible:border-fuchsia-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-fuchsia-700">A pink and blue harbor scene printed by hand on recycled stock.</textarea>
      </div>
    </div>
    <div class="sticky bottom-0 flex flex-wrap items-center gap-3 border-t border-neutral-200 bg-white px-6 py-4 shadow-[0_-8px_16px_-12px_rgb(23_23_23/0.25)]">
      <p class="mr-auto flex items-center gap-2 text-sm text-neutral-700" role="status">
        <span class="hidden size-2 rounded-full bg-amber-500 group-data-dirty:inline-block" aria-hidden="true"></span>
        <span class="group-data-dirty:hidden">All changes saved</span>
        <span class="hidden group-data-dirty:inline">Unsaved changes</span>
      </p>
      <button type="reset" class="rounded-lg px-4 py-2.5 text-sm font-semibold text-neutral-800 hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fuchsia-700">Discard</button>
      <button type="submit" class="rounded-lg bg-fuchsia-800 px-5 py-2.5 text-sm font-semibold text-white hover:bg-fuchsia-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fuchsia-700">Save</button>
    </div>
  </form>
</div>

Newsletter, survey and file upload forms

Smaller patterns that show up on almost every site: an inline signup, a survey grouped with fieldset and legend, and a drag-and-drop upload zone wrapped around a hidden file input.

Inline newsletter signup on dark

  • flex-col sm:flex-row
  • min-w-0 flex-1
  • sr-only
  • rounded-full
  • placeholder:text-*

Enter an email and subscribe.

One flex row holds the email field and the button from sm: up and stacks them on phones, and min-w-0 flex-1 lets the input shrink instead of pushing the button out of the card. The label is sr-only because the heading and placeholder already explain the field visually. The script swaps in a confirmation once the browser has accepted the email.

View code
<div class="w-full max-w-2xl rounded-3xl bg-slate-900 p-6 ring-1 ring-white/10 sm:p-10">
  <h2 class="text-2xl font-semibold tracking-tight text-white sm:text-3xl">The Friday Grid</h2>
  <p class="mt-3 max-w-lg text-base text-slate-300">One layout trick, one CSS property and one site worth studying. Every Friday, read in under five minutes.</p>
  <form class="tfm-nl-form mt-6 flex flex-col gap-3 sm:flex-row">
    <label for="tfm-nl-email" class="sr-only">Email address</label>
    <input id="tfm-nl-email" type="email" autocomplete="email" required placeholder="you@example.com" class="min-w-0 flex-1 rounded-full border border-slate-500 bg-slate-950 px-5 py-3 text-base text-white placeholder:text-slate-400 focus-visible:border-lime-300 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-lime-300">
    <button type="submit" class="rounded-full bg-lime-300 px-6 py-3 text-base font-semibold text-slate-950 hover:bg-lime-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lime-300">Subscribe</button>
  </form>
  <p id="tfm-nl-status" class="mt-4 text-sm text-slate-400" role="status">No spam. Unsubscribe with one click.</p>
</div>

Survey grouped with fieldsets

  • @theme
  • peer-checked:
  • peer-focus-visible:
  • accent-*
  • fieldset + legend

Rate the workshop and tick a session.

Each question is a fieldset with a legend, so screen readers announce the question with every option. The 1 to 5 scale hides real radios with sr-only and styles the span after each one with peer-checked: and peer-focus-visible:, while the session checkboxes and the yes or no radios stay native and take accent-tfm-plum. That plum color is a custom token defined once with @theme in the CSS.

View code
<form class="tfm-sv-form w-full max-w-xl space-y-8 rounded-2xl bg-white p-6 shadow-sm ring-1 ring-stone-900/10 sm:p-8">
  <div>
    <h2 class="text-xl font-semibold text-stone-900">How was Grid Camp?</h2>
    <p class="mt-1 text-sm text-stone-600">Three questions, all optional except the first.</p>
  </div>
  <fieldset>
    <legend class="text-base font-semibold text-stone-900">How useful was the workshop overall?</legend>
    <div class="mt-3 flex gap-2">
      <label class="flex-1"><input type="radio" name="tfm-sv-rate" value="1" required class="peer sr-only"><span class="grid h-12 cursor-pointer place-items-center rounded-lg border border-stone-400 text-base font-semibold text-stone-800 hover:border-tfm-plum peer-checked:border-tfm-plum peer-checked:bg-tfm-plum peer-checked:text-white peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-tfm-plum">1<span class="sr-only">, not useful</span></span></label>
      <label class="flex-1"><input type="radio" name="tfm-sv-rate" value="2" class="peer sr-only"><span class="grid h-12 cursor-pointer place-items-center rounded-lg border border-stone-400 text-base font-semibold text-stone-800 hover:border-tfm-plum peer-checked:border-tfm-plum peer-checked:bg-tfm-plum peer-checked:text-white peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-tfm-plum">2</span></label>
      <label class="flex-1"><input type="radio" name="tfm-sv-rate" value="3" class="peer sr-only"><span class="grid h-12 cursor-pointer place-items-center rounded-lg border border-stone-400 text-base font-semibold text-stone-800 hover:border-tfm-plum peer-checked:border-tfm-plum peer-checked:bg-tfm-plum peer-checked:text-white peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-tfm-plum">3</span></label>
      <label class="flex-1"><input type="radio" name="tfm-sv-rate" value="4" id="tfm-sv-r4" class="peer sr-only"><span class="grid h-12 cursor-pointer place-items-center rounded-lg border border-stone-400 text-base font-semibold text-stone-800 hover:border-tfm-plum peer-checked:border-tfm-plum peer-checked:bg-tfm-plum peer-checked:text-white peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-tfm-plum">4</span></label>
      <label class="flex-1"><input type="radio" name="tfm-sv-rate" value="5" class="peer sr-only"><span class="grid h-12 cursor-pointer place-items-center rounded-lg border border-stone-400 text-base font-semibold text-stone-800 hover:border-tfm-plum peer-checked:border-tfm-plum peer-checked:bg-tfm-plum peer-checked:text-white peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-tfm-plum">5<span class="sr-only">, very useful</span></span></label>
    </div>
    <div class="mt-2 flex justify-between text-sm text-stone-600" aria-hidden="true"><span>Not useful</span><span>Very useful</span></div>
  </fieldset>
  <fieldset>
    <legend class="text-base font-semibold text-stone-900">Which sessions did you attend?</legend>
    <div class="mt-3 grid gap-3 text-sm text-stone-800 sm:grid-cols-2">
      <label class="flex items-center gap-3"><input type="checkbox" id="tfm-sv-s1" class="size-4 shrink-0 accent-tfm-plum">Subgrid in practice</label>
      <label class="flex items-center gap-3"><input type="checkbox" class="size-4 shrink-0 accent-tfm-plum">Container queries</label>
      <label class="flex items-center gap-3"><input type="checkbox" class="size-4 shrink-0 accent-tfm-plum">Masonry layouts</label>
      <label class="flex items-center gap-3"><input type="checkbox" class="size-4 shrink-0 accent-tfm-plum">Print stylesheets</label>
    </div>
  </fieldset>
  <fieldset>
    <legend class="text-base font-semibold text-stone-900">Would you come back next year?</legend>
    <div class="mt-3 flex flex-wrap gap-x-6 gap-y-3 text-sm text-stone-800">
      <label class="flex items-center gap-2"><input type="radio" name="tfm-sv-back" class="size-4 accent-tfm-plum">Yes</label>
      <label class="flex items-center gap-2"><input type="radio" name="tfm-sv-back" class="size-4 accent-tfm-plum">Maybe</label>
      <label class="flex items-center gap-2"><input type="radio" name="tfm-sv-back" class="size-4 accent-tfm-plum">No</label>
    </div>
  </fieldset>
  <div>
    <label for="tfm-sv-note" class="block text-base font-semibold text-stone-900">Anything we should change?</label>
    <textarea id="tfm-sv-note" rows="3" class="mt-2 block w-full resize-y rounded-lg border border-stone-500 bg-white px-3.5 py-2.5 text-base text-stone-900 focus-visible:border-tfm-plum focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-tfm-plum"></textarea>
  </div>
  <div class="flex flex-wrap items-center gap-4">
    <button type="submit" class="rounded-lg bg-tfm-plum px-6 py-3 text-base font-semibold text-white hover:bg-tfm-plum-dark focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tfm-plum">Send answers</button>
    <p class="tfm-sv-status text-sm font-medium text-tfm-plum empty:hidden" role="status"></p>
  </div>
</form>

File upload drop zone

  • border-dashed
  • data-drag:
  • has-focus-visible:
  • file:
  • sr-only

Click the zone or drop files on it.

The drop zone is a label wrapped around an sr-only file input, so a click or Enter opens the native picker and has-focus-visible: shows the keyboard ring on the zone. Dragging sets data-drag on the label for the data-drag: colors, and the script lists the chosen files with remove buttons built through createElement. The second field keeps the native input and styles its button with the file: variant.

View code
<form class="tfm-fu-form w-full max-w-xl rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-900/10 sm:p-8">
  <h2 class="text-xl font-semibold text-slate-900">Send your brand assets</h2>
  <p class="mt-1 text-sm text-slate-600">Logos, photos and the current style guide. We start the audit as soon as they arrive.</p>
  <label id="tfm-fu-zone" class="mt-5 flex cursor-pointer flex-col items-center rounded-2xl border-2 border-dashed border-slate-400 bg-slate-50 px-6 py-10 text-center transition-colors hover:border-sky-700 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-sky-700 data-drag:border-sky-700 data-drag:bg-sky-50 motion-reduce:transition-none">
    <svg class="size-10 text-slate-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 15V4m0 0L8 8m4-4 4 4"/><path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/></svg>
    <span class="mt-3 text-base font-semibold text-slate-900">Drop files here or <span class="text-sky-800 underline underline-offset-2">browse</span></span>
    <span class="mt-1 text-sm text-slate-600">PNG, JPG or PDF, up to 10 MB each</span>
    <input id="tfm-fu-input" type="file" multiple accept=".png,.jpg,.jpeg,.pdf" class="sr-only">
  </label>
  <ul id="tfm-fu-list" class="mt-4 space-y-2 empty:hidden" aria-label="Selected files"></ul>
  <div class="mt-6">
    <label for="tfm-fu-contract" class="block text-sm font-medium text-slate-800">Signed agreement (PDF)</label>
    <input id="tfm-fu-contract" type="file" accept=".pdf" class="mt-2 block w-full text-sm text-slate-700 file:mr-4 file:rounded-full file:border-0 file:bg-sky-100 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-sky-900 hover:file:bg-sky-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700">
  </div>
  <div class="mt-6 flex flex-wrap items-center gap-4">
    <button type="submit" id="tfm-fu-send" disabled class="rounded-lg bg-sky-800 px-5 py-3 text-base font-semibold text-white hover:bg-sky-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-800 disabled:cursor-not-allowed disabled:bg-slate-200 disabled:text-slate-600">Upload files</button>
    <p class="tfm-fu-status text-sm font-medium text-emerald-800 empty:hidden" role="status"></p>
  </div>
</form>

Validation, dark mode and disabled states

State variants doing the heavy lifting: user-invalid: and peer messages, aria-invalid errors returned by a server, a form that switches to dark mode with one class, and a fieldset that locks every field at once.

Live validation with user-invalid: and peer messages

  • user-invalid:
  • peer-user-invalid:
  • peer-user-valid:
  • group-invalid:
  • pattern

Type a wrong value, then move to the next field.

Constraint attributes (required, type, pattern, min) decide validity, and user-invalid: only colors a field after someone has edited and left it, unlike invalid:, which would paint the empty form red on load. The message and icons are siblings of the input, so peer-user-invalid: and peer-user-valid: reveal them, and group-invalid: and group-valid: on the form switch the summary line. The script only handles the demo submit.

View code
<form class="tfm-lv-form group w-full max-w-md space-y-5 rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-900/10 sm:p-8">
  <div>
    <h2 class="text-xl font-semibold text-slate-900">Speaker profile</h2>
    <p class="mt-1 text-sm text-slate-600">Fields check themselves once you leave them.</p>
  </div>
  <div>
    <label for="tfm-lv-email" class="block text-sm font-medium text-slate-800">Email</label>
    <div class="relative mt-1.5">
      <input id="tfm-lv-email" type="email" required autocomplete="email" aria-describedby="tfm-lv-email-msg" class="peer block w-full rounded-lg border border-slate-500 bg-white py-2.5 pr-10 pl-3.5 text-base text-slate-900 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-slate-900 user-valid:border-emerald-700 user-invalid:border-rose-600 user-invalid:bg-rose-50 user-invalid:focus-visible:outline-rose-600">
      <svg class="pointer-events-none absolute top-3.5 right-3 hidden size-5 text-emerald-700 peer-user-valid:block" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m4.5 10.5 3.5 3.5 7.5-8"/></svg>
      <svg class="pointer-events-none absolute top-3.5 right-3 hidden size-5 text-rose-600 peer-user-invalid:block" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="10" cy="10" r="7.5"/><path d="M10 6v4.5M10 13.5v.5"/></svg>
      <p id="tfm-lv-email-msg" class="mt-1.5 hidden text-sm text-rose-700 peer-user-invalid:block">Use a full address, like name@example.com.</p>
    </div>
  </div>
  <div>
    <label for="tfm-lv-user" class="block text-sm font-medium text-slate-800">Handle</label>
    <div class="relative mt-1.5">
      <input id="tfm-lv-user" type="text" required pattern="[a-z0-9\-]{3,16}" autocomplete="off" aria-describedby="tfm-lv-user-msg" class="peer block w-full rounded-lg border border-slate-500 bg-white py-2.5 pr-10 pl-3.5 text-base text-slate-900 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-slate-900 user-valid:border-emerald-700 user-invalid:border-rose-600 user-invalid:bg-rose-50 user-invalid:focus-visible:outline-rose-600">
      <svg class="pointer-events-none absolute top-3.5 right-3 hidden size-5 text-emerald-700 peer-user-valid:block" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m4.5 10.5 3.5 3.5 7.5-8"/></svg>
      <svg class="pointer-events-none absolute top-3.5 right-3 hidden size-5 text-rose-600 peer-user-invalid:block" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="10" cy="10" r="7.5"/><path d="M10 6v4.5M10 13.5v.5"/></svg>
      <p id="tfm-lv-user-msg" class="mt-1.5 text-sm text-slate-600 peer-user-invalid:text-rose-700">3 to 16 lowercase letters, numbers or hyphens.</p>
    </div>
  </div>
  <div>
    <label for="tfm-lv-years" class="block text-sm font-medium text-slate-800">Years speaking</label>
    <div class="relative mt-1.5">
      <input id="tfm-lv-years" type="number" required min="0" max="60" aria-describedby="tfm-lv-years-msg" class="peer block w-full rounded-lg border border-slate-500 bg-white py-2.5 pr-10 pl-3.5 text-base text-slate-900 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-slate-900 user-valid:border-emerald-700 user-invalid:border-rose-600 user-invalid:bg-rose-50 user-invalid:focus-visible:outline-rose-600">
      <p id="tfm-lv-years-msg" class="mt-1.5 hidden text-sm text-rose-700 peer-user-invalid:block">Enter a number from 0 to 60.</p>
    </div>
  </div>
  <p class="hidden items-center gap-2 text-sm text-slate-600 group-invalid:flex"><span class="size-2 rounded-full bg-slate-400" aria-hidden="true"></span>Complete all three fields to submit.</p>
  <p class="hidden items-center gap-2 text-sm font-medium text-emerald-800 group-valid:flex"><span class="size-2 rounded-full bg-emerald-600" aria-hidden="true"></span>Everything looks good.</p>
  <button type="submit" class="w-full rounded-lg bg-slate-900 px-4 py-3 text-base font-semibold text-white hover:bg-slate-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-900">Save profile</button>
  <p class="tfm-lv-status text-sm font-medium text-emerald-800 empty:hidden" role="status"></p>
</form>

Server errors with aria-invalid and a summary

  • aria-[invalid=true]:
  • novalidate
  • aria-describedby
  • animate-spin
  • motion-reduce:hidden

Enter a short new password and press Update.

For errors that only the server can know, the script sets aria-invalid="true" and aria-describedby on each failing field, and aria-[invalid=true]: utilities restyle it, so the same classes work whatever framework renders the form. An error summary with in-page links receives focus after the check, so keyboard and screen reader users land on the list of problems first. The spinner uses animate-spin and is hidden for reduced motion.

View code
<form class="tfm-se-form w-full max-w-md space-y-5 rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-900/10 sm:p-8" novalidate>
  <div>
    <h2 class="text-xl font-semibold text-slate-900">Change password</h2>
    <p class="mt-1 text-sm text-slate-600">You will stay signed in on this device.</p>
  </div>
  <div id="tfm-se-summary" tabindex="-1" hidden class="rounded-lg border border-red-300 bg-red-50 p-4 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-700">
    <h3 id="tfm-se-summary-title" class="text-sm font-semibold text-red-900"></h3>
    <ul id="tfm-se-summary-list" class="mt-2 list-disc space-y-1 pl-5 text-sm text-red-800"></ul>
  </div>
  <div>
    <label for="tfm-se-current" class="block text-sm font-medium text-slate-800">Current password</label>
    <input id="tfm-se-current" type="password" autocomplete="current-password" class="mt-1.5 block w-full rounded-lg border border-slate-500 bg-white px-3.5 py-2.5 text-base text-slate-900 focus-visible:border-blue-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-blue-700 aria-[invalid=true]:border-red-700 aria-[invalid=true]:bg-red-50 aria-[invalid=true]:focus-visible:outline-red-700">
    <p id="tfm-se-current-err" hidden class="mt-1.5 text-sm text-red-700"></p>
  </div>
  <div>
    <label for="tfm-se-new" class="block text-sm font-medium text-slate-800">New password</label>
    <input id="tfm-se-new" type="password" autocomplete="new-password" class="mt-1.5 block w-full rounded-lg border border-slate-500 bg-white px-3.5 py-2.5 text-base text-slate-900 focus-visible:border-blue-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-blue-700 aria-[invalid=true]:border-red-700 aria-[invalid=true]:bg-red-50 aria-[invalid=true]:focus-visible:outline-red-700">
    <p id="tfm-se-new-err" hidden class="mt-1.5 text-sm text-red-700"></p>
  </div>
  <div>
    <label for="tfm-se-confirm" class="block text-sm font-medium text-slate-800">Repeat new password</label>
    <input id="tfm-se-confirm" type="password" autocomplete="new-password" class="mt-1.5 block w-full rounded-lg border border-slate-500 bg-white px-3.5 py-2.5 text-base text-slate-900 focus-visible:border-blue-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-blue-700 aria-[invalid=true]:border-red-700 aria-[invalid=true]:bg-red-50 aria-[invalid=true]:focus-visible:outline-red-700">
    <p id="tfm-se-confirm-err" hidden class="mt-1.5 text-sm text-red-700"></p>
  </div>
  <button type="submit" id="tfm-se-submit" class="flex w-full items-center justify-center gap-2 rounded-lg bg-blue-700 px-4 py-3 text-base font-semibold text-white hover:bg-blue-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-700 disabled:cursor-wait disabled:opacity-75">
    <svg id="tfm-se-spin" hidden class="size-5 animate-spin motion-reduce:hidden" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-opacity="0.3" stroke-width="3"/><path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg>
    <span id="tfm-se-label">Update password</span>
  </button>
  <p class="tfm-se-status text-sm font-medium text-emerald-800 empty:hidden" role="status"></p>
</form>

Form with a dark mode switch

  • dark:
  • @custom-variant dark
  • dark:scheme-dark
  • group-aria-pressed:
  • accent-*

Press the Dark mode button.

Every control carries its light classes and a dark: twin, and the button only adds or removes the dark class on the card, which the @custom-variant dark line turns into a scoped dark theme. dark:scheme-dark switches color-scheme so the native select popup and checkbox follow along, and group-aria-pressed: swaps the moon for a sun inside the toggle.

View code
<div id="tfm-dk-card" class="w-full max-w-lg rounded-3xl bg-white p-6 shadow-sm ring-1 ring-slate-900/10 transition-colors sm:p-8 dark:bg-slate-900 dark:ring-white/10 dark:scheme-dark motion-reduce:transition-none">
  <div class="flex flex-wrap items-start justify-between gap-4">
    <div>
      <h2 class="text-xl font-semibold text-slate-900 dark:text-white">Invite your team</h2>
      <p class="mt-1 text-sm text-slate-600 dark:text-slate-400">They get an email with a one-time link.</p>
    </div>
    <button type="button" id="tfm-dk-toggle" aria-pressed="false" class="group inline-flex items-center gap-2 rounded-full border border-slate-400 px-3.5 py-2 text-sm font-medium text-slate-800 hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:border-slate-500 dark:text-slate-100 dark:hover:bg-slate-800 dark:focus-visible:outline-indigo-400">
      <svg class="size-4 group-aria-pressed:hidden" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16.5 12.5A7 7 0 0 1 7.5 3.5a7 7 0 1 0 9 9Z"/></svg>
      <svg class="hidden size-4 group-aria-pressed:block" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" aria-hidden="true"><circle cx="10" cy="10" r="3.5"/><path d="M10 1.5v2M10 16.5v2M1.5 10h2M16.5 10h2M4 4l1.4 1.4M14.6 14.6 16 16M4 16l1.4-1.4M14.6 5.4 16 4"/></svg>
      Dark mode
    </button>
  </div>
  <form class="tfm-dk-form mt-6 space-y-5">
    <div>
      <label for="tfm-dk-emails" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Email addresses</label>
      <input id="tfm-dk-emails" type="text" placeholder="ana@example.com, ravi@example.com" class="mt-1.5 block w-full rounded-lg border border-slate-500 bg-white px-3.5 py-2.5 text-base text-slate-900 placeholder:text-slate-500 focus-visible:border-indigo-600 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-indigo-600 dark:border-slate-500 dark:bg-slate-950 dark:text-white dark:placeholder:text-slate-400 dark:focus-visible:border-indigo-400 dark:focus-visible:outline-indigo-400">
    </div>
    <div>
      <label for="tfm-dk-role" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Role</label>
      <div class="mt-1.5 grid">
        <select id="tfm-dk-role" class="col-start-1 row-start-1 w-full appearance-none rounded-lg border border-slate-500 bg-white py-2.5 pr-10 pl-3.5 text-base text-slate-900 focus-visible:border-indigo-600 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-indigo-600 dark:border-slate-500 dark:bg-slate-950 dark:text-white dark:focus-visible:border-indigo-400 dark:focus-visible:outline-indigo-400">
          <option>Viewer</option>
          <option selected>Editor</option>
          <option>Admin</option>
        </select>
        <svg class="pointer-events-none col-start-1 row-start-1 mr-3 size-5 self-center justify-self-end text-slate-600 dark:text-slate-400" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 8 4 4 4-4"/></svg>
      </div>
    </div>
    <div>
      <label for="tfm-dk-note" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Personal note</label>
      <textarea id="tfm-dk-note" rows="3" class="mt-1.5 block w-full resize-y rounded-lg border border-slate-500 bg-white px-3.5 py-2.5 text-base text-slate-900 focus-visible:border-indigo-600 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-indigo-600 dark:border-slate-500 dark:bg-slate-950 dark:text-white dark:focus-visible:border-indigo-400 dark:focus-visible:outline-indigo-400">Welcome aboard. The launch board is pinned in the sidebar.</textarea>
    </div>
    <label class="flex items-center gap-3 text-sm text-slate-700 dark:text-slate-300">
      <input type="checkbox" checked class="size-4 shrink-0 accent-indigo-600 dark:accent-indigo-400">
      Send me a copy of the invite
    </label>
    <div class="flex flex-wrap items-center gap-4">
      <button type="submit" class="rounded-lg bg-indigo-600 px-5 py-3 text-base font-semibold text-white hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:text-white dark:hover:bg-indigo-400 dark:focus-visible:outline-indigo-400">Send invites</button>
      <p class="tfm-dk-status text-sm font-medium text-emerald-800 empty:hidden dark:text-emerald-300" role="status"></p>
    </div>
  </form>
</div>

Locked billing details with one fieldset

  • fieldset disabled
  • disabled:
  • group-disabled:
  • cursor-not-allowed
  • appearance-none

Press Edit to unlock the fields.

The disabled attribute on a single fieldset disables every control inside it, and each input styles that with disabled: utilities. Because the fieldset itself matches :disabled, giving it the group class lets group-disabled: dim the labels and show the lock note. The Edit button only flips fieldset.disabled and moves focus to the first field.

View code
<form class="tfm-lk-form w-full max-w-xl rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-900/10 sm:p-8">
  <div class="flex flex-wrap items-start justify-between gap-4">
    <div>
      <h2 class="text-xl font-semibold text-slate-900">Billing details</h2>
      <p class="mt-1 text-sm text-slate-600">Printed on every invoice from Cobalt Cloud.</p>
    </div>
    <button type="button" id="tfm-lk-edit" class="rounded-lg border border-cyan-800 px-4 py-2.5 text-sm font-semibold text-cyan-900 hover:bg-cyan-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cyan-800">Edit</button>
  </div>
  <fieldset id="tfm-lk-fields" disabled class="group mt-6 grid gap-5 sm:grid-cols-2">
    <legend class="sr-only">Company billing address</legend>
    <p class="hidden items-center gap-2 rounded-lg bg-slate-100 px-3 py-2 text-sm text-slate-700 group-disabled:flex sm:col-span-2">
      <svg class="size-4 shrink-0" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="7" width="10" height="7" rx="1.5"/><path d="M5.5 7V5a2.5 2.5 0 0 1 5 0v2"/></svg>
      Locked. Press Edit to change these details.
    </p>
    <div class="sm:col-span-2">
      <label for="tfm-lk-company" class="block text-sm font-medium text-slate-800 group-disabled:text-slate-600">Company</label>
      <input id="tfm-lk-company" type="text" value="Northwind Ceramics Ltd" autocomplete="organization" class="mt-1.5 block w-full rounded-lg border border-slate-500 bg-white px-3.5 py-2.5 text-base text-slate-900 focus-visible:border-cyan-800 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-cyan-800 disabled:cursor-not-allowed disabled:border-slate-300 disabled:bg-slate-50 disabled:text-slate-600">
    </div>
    <div>
      <label for="tfm-lk-vat" class="block text-sm font-medium text-slate-800 group-disabled:text-slate-600">VAT number</label>
      <input id="tfm-lk-vat" type="text" value="GB 000 1234 56" class="mt-1.5 block w-full rounded-lg border border-slate-500 bg-white px-3.5 py-2.5 text-base text-slate-900 focus-visible:border-cyan-800 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-cyan-800 disabled:cursor-not-allowed disabled:border-slate-300 disabled:bg-slate-50 disabled:text-slate-600">
    </div>
    <div>
      <label for="tfm-lk-email" class="block text-sm font-medium text-slate-800 group-disabled:text-slate-600">Invoice email</label>
      <input id="tfm-lk-email" type="email" value="accounts@example.com" class="mt-1.5 block w-full rounded-lg border border-slate-500 bg-white px-3.5 py-2.5 text-base text-slate-900 focus-visible:border-cyan-800 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-cyan-800 disabled:cursor-not-allowed disabled:border-slate-300 disabled:bg-slate-50 disabled:text-slate-600">
    </div>
    <div class="sm:col-span-2">
      <label for="tfm-lk-street" class="block text-sm font-medium text-slate-800 group-disabled:text-slate-600">Address</label>
      <input id="tfm-lk-street" type="text" value="4 Kiln Yard, Stoke Road" autocomplete="street-address" class="mt-1.5 block w-full rounded-lg border border-slate-500 bg-white px-3.5 py-2.5 text-base text-slate-900 focus-visible:border-cyan-800 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-cyan-800 disabled:cursor-not-allowed disabled:border-slate-300 disabled:bg-slate-50 disabled:text-slate-600">
    </div>
    <div>
      <label for="tfm-lk-country" class="block text-sm font-medium text-slate-800 group-disabled:text-slate-600">Country</label>
      <div class="mt-1.5 grid">
        <select id="tfm-lk-country" class="col-start-1 row-start-1 w-full appearance-none rounded-lg border border-slate-500 bg-white py-2.5 pr-10 pl-3.5 text-base text-slate-900 focus-visible:border-cyan-800 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-cyan-800 disabled:cursor-not-allowed disabled:border-slate-300 disabled:bg-slate-50 disabled:text-slate-600">
          <option selected>United Kingdom</option>
          <option>Ireland</option>
          <option>Netherlands</option>
        </select>
        <svg class="pointer-events-none col-start-1 row-start-1 mr-3 size-5 self-center justify-self-end text-slate-600" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 8 4 4 4-4"/></svg>
      </div>
    </div>
    <div>
      <label for="tfm-lk-zip" class="block text-sm font-medium text-slate-800 group-disabled:text-slate-600">Postcode</label>
      <input id="tfm-lk-zip" type="text" value="ST1 0AA" autocomplete="postal-code" class="mt-1.5 block w-full rounded-lg border border-slate-500 bg-white px-3.5 py-2.5 text-base text-slate-900 focus-visible:border-cyan-800 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-cyan-800 disabled:cursor-not-allowed disabled:border-slate-300 disabled:bg-slate-50 disabled:text-slate-600">
    </div>
    <div class="flex justify-end sm:col-span-2 group-disabled:hidden">
      <button type="submit" class="rounded-lg bg-cyan-800 px-5 py-3 text-base font-semibold text-white hover:bg-cyan-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cyan-800">Save details</button>
    </div>
  </fieldset>
  <p class="tfm-lk-status mt-4 text-sm font-medium text-emerald-800 empty:hidden" role="status"></p>
</form>

What Are Tailwind Forms?

The @tailwindcss/forms plugin gives native inputs, selects, checkboxes and radios one consistent baseline and leaves the visual design to utility classes. Tailwind Labs maintains it, npm distributes it, and it ships under the MIT license.

Browser-specific styling is the reason it exists. Padding, rounded corners or a custom checkbox color often do nothing until appearance: none is applied. That gap is a cross-browser compatibility problem, and the plugin closes it at the base layer.

It builds on the utility-first model of Tailwind CSS and brings no components of its own. Text-like inputs, textarea, select, checkbox and radio all get the same baseline, and each one then accepts padding, border, rounded and color utilities directly. Prebuilt components, labels and layouts are missing, and so are error messages, validation logic and form state. The plugin also has no opinion on brand colors, spacing or typography.

The Tailwind Labs README describes it as a form reset, not a set of component styles. That framing explains most of how it behaves.

Which Form Elements Does the Plugin Reset?

The reset covers 17 selectors (Tailwind Labs README). Of those, 12 are text-like input types: text, password, email, number, url, date, datetime-local, month, week, time, search and tel. The rest are checkbox, radio, select, select[multiple] and textarea.

Each element gets the same minimal baseline of border and padding, with a focus ring on top. Select also gets a chevron drawn as a background image, and checkbox and radio add a checked fill that follows the text color.

Since release 0.5.4, an input with no type attribute gets the text input styles too (project changelog).

File and range inputs are not on the list, so they keep native styling.

As of October 2026, the latest release is 0.5.11 (December 17, 2025), which limits the plugin’s attribute rules to input and select elements, according to the project changelog.

How to Install the Forms Plugin in Tailwind v4 and v3

Run npm install -D @tailwindcss/forms, then register the plugin with an @plugin line in your stylesheet (Tailwind v4) or in the plugins array of tailwind.config.js (Tailwind v3).

  1. Install the package from npm as a dev dependency.
  2. Register it for your Tailwind version (details below).
  3. Run the dev server or build step (Vite, PostCSS or the Tailwind CLI) so the stylesheet that holds the directive gets processed.
  4. Add a bare select and a checkbox to a page. If the native browser styling is gone, the plugin is loaded.

Tailwind has to be running first, so set up the framework itself before adding the plugin.

Tailwind v4 setup

Add two lines to the main stylesheet:

@import "tailwindcss";

@plugin "@tailwindcss/forms";

Tailwind’s documentation files @plugin under the compatibility directives for loading JavaScript plugins, and it accepts a package name or a local path.

If nothing changes after an upgrade, check that @plugin follows @import and that the build processes that CSS file. The UAE Design System documentation uses this exact pattern in its v4 starter stylesheet, next to the typography plugin.

Tailwind v3 setup

One config entry does it.

plugins: [require('@tailwindcss/forms')]

Put that inside tailwind.config.js.

The reset rides on the base layer, so a stylesheet without the @tailwind base directive gets none of it. In v3, that directive injects Tailwind’s base styles plus any base styles registered by plugins (Tailwind v3 documentation).

Base or Class Strategy: Which One Fits Your Project?

For a new project, the default is the right call. Class makes sense when the plugin has to share a page with existing form CSS or a third-party widget, and base only fits if no form-* class will ever be needed.

StrategyGlobal stylesform-* classesBest fit
Default (no option)YesYesNew projects
baseYesNoNew projects, global reset only
classNoYesExisting CSS, custom select or date picker libraries

The class strategy exists for custom widgets. According to the changelog, it was added for custom select and date picker libraries.

Old tutorials disagree with the current default, which changed in release 0.5.0 (March 2, 2022). Before that the default was base only, and 0.5.0 made it generate both global styles and classes.

Under the class strategy, form-input handles the 12 text-like input types and form-textarea handles textarea. form-select styles select, form-multiselect styles select[multiple], and form-checkbox and form-radio cover the last two.

Because the classes are generated as well, a div can look like an input by carrying form-input. That is the use case the README gives for them.

In Tailwind v4 the option goes inside the plugin block: @plugin "@tailwindcss/forms" { strategy: "class"; }. In v3 it is passed as an argument instead: require("@tailwindcss/forms")({ strategy: 'class' }).

The catch with class is that a bare input keeps its native look until form-input is added, because no global reset runs.

How to Customize Inputs, Selects, Checkboxes and Radios

Utilities apply directly to the reset controls. Padding, border, rounded and focus utilities work on inputs and selects, and a text color utility sets the checked fill on checkboxes and radios.

Text inputs and textarea

Text-like inputs and textarea take the same utilities as any other element. For a wider look at how Tailwind styles input fields, that page covers the general patterns.

Common choices are px-4 py-3 for spacing, rounded-lg or rounded-full for shape, and border-gray-300 for the border. For focus, focus:border-indigo-500 focus:ring-indigo-500 is the usual pair.

Under the class strategy, add form-input or form-textarea first, then the utilities: form-input rounded-full px-4 py-3 (Tailwind Labs README).

Select and multiselect

Padding finally works on a select.

The README example is rounded-full px-4 py-3 on a bare select, which used to need custom CSS.

The chevron is part of the baseline. Release 0.5.4 (July 13, 2023) removed it from selects with a non-default size, so a select given a size attribute renders without one.

Under the class strategy a single select takes form-select and a multiple select takes form-multiselect. With base, the multiple attribute does the job.

Checkboxes and radio buttons

Set the checked color with a text color utility. rounded text-pink-500 on a checkbox turns the checked fill pink (Tailwind Labs README).

Radios follow the same rule, and there is a separate walkthrough on styling radio buttons in Tailwind.

Size comes from height and width utilities such as h-5 w-5.

Since release 0.5.7 (November 10, 2023), checkboxes and radios keep their normal appearance in forced-colors mode, the mode behind Windows high contrast themes. Check the chosen fill color against its background anyway.

How to Build a Responsive Form Layout

Default to one column, move to a two-column grid from the sm breakpoint up, and let wide fields span both columns.

Treating the form as a grid layout keeps spacing consistent, because one gap utility spaces every cell and no field needs its own margin.

Tailwind follows mobile-first design, so unprefixed classes apply at every width and prefixed ones apply from their breakpoint up. A wrapper of grid grid-cols-1 gap-4 sm:grid-cols-2 gives one column on phones and two on larger screens.

FieldControlWidth classes
First nameinputnone (one cell)
Last nameinputnone (one cell)
Emailinput, type emailsm:col-span-2
Messagetextareasm:col-span-2
Submitbuttonsm:col-span-2

For label pairing, give each input an id and point the label’s for attribute at it. Wrapping the input inside the label also works, and it is the pattern Tailwind’s own state documentation uses. Add block w-full to the input so it fills its grid cell.

Helper text sits under the control in text-sm text-gray-500. Capping the form width with max-w-sm mx-auto is a quick way to stop inputs stretching across a wide screen.

Styling Focus, Invalid and Disabled States

State variants let one class list style every condition. The focus:, invalid:, required: and disabled: prefixes sit beside the base utilities and the browser decides which one applies. Tailwind’s documentation notes that this cuts the conditional logic a template needs.

Stacking focus:invalid:border-pink-500 keeps the error color on a field that has focus (Tailwind documentation example). A plain invalid style is invalid:border-pink-500 invalid:text-pink-600, and a disabled one is disabled:bg-gray-50 disabled:text-gray-500 disabled:border-gray-200.

The invalid: variant matches the moment a field fails its constraints, so an empty required input is already flagged on page load. The native :user-invalid pseudo-class waits for interaction. Web platform Baseline data lists it as newly available since November 2023 and widely available since May 2026.

Tailwind v4.1 and later include user-invalid: and user-valid: variants, so user-invalid:border-pink-500 works directly. On v3, reach it with an arbitrary variant: [&:user-invalid]:border-pink-500.

For error messages, mark the input with the peer class and hide its message with invisible peer-invalid:visible, the pattern in Tailwind’s documentation. For errors set by JavaScript, use aria-[invalid=true]:border-pink-500 together with aria-invalid and aria-describedby, as described in guides to accessible forms.

Color alone does not communicate an error. MDN warns that people who struggle to tell colors apart cannot read a red border as invalid, so pair it with text or an icon.

WCAG 2.2 success criterion 1.4.11 requires at least 3:1 contrast between the visual information needed to identify a control or its states and the adjacent colors (W3C). Light borders such as border-gray-300 on white are the common casualty when the border is what identifies the field, so measure each border and focus ring for color contrast.

Disabled fields are exempt from that rule, which makes opacity a legitimate way to dim them.

When Does the Forms Plugin Not Work?

File inputs, range inputs, Safari date and time rendering, accent-color utilities on checkboxes and the Play CDN are where the plugin lets you down.

CaseWhat you seeWorkaround
File inputNative button and label textfile: variant on the input
Range inputNative slideraccent-* utilities tint it
Date and time, SafariSpacing and alignment glitchesUpdate to 0.5.6 or later
accent-* on checkbox or radioNo visible changeText color sets the checked fill
Play CDNPlugin styles missing unless the plugin is enabled; Tailwind’s v4 Play CDN docs describe no way to load itv3: add ?plugins=forms to the script URL. v4: use a build step

Date and time inputs are the most patched control type in the project. Four changelog entries (0.1.2, 0.4.1, 0.5.5 and 0.5.6) fix date or time rendering in Safari or on iOS, so test them on a real iPhone before shipping.

Tailwind’s accent-* utilities override the browser’s default color on native checkboxes and radio groups. The plugin removes that native rendering, which means text color is the lever that works.

The select chevron has its own wrinkle. Release 0.5.9 (September 5, 2024) falls back to a static chevron color when the theme uses CSS variables, so check the icon against dark backgrounds.

On the Play CDN, Tailwind’s v3 documentation shows the forms plugin loading through a ?plugins=forms query on the script URL. Tailwind’s v4 Play CDN page documents no equivalent. Both versions’ Play CDN pages call it a development tool: v4 says it is not intended for production, and v3 says it is not the best choice for production.

Forms Plugin or a Component Library?

If you own the design and only want a reset, the forms plugin is enough. A component library earns its place when you would rather get finished controls than write custom markup.

OptionWhat it providesStyling approachBest fit
Forms pluginReset for native controlsUtilities on native elementsCustom design systems
FlowbitePrebuilt inputs, selects, file upload, togglesTailwind classes in component markupReady-made forms
daisyUIClasses such as input, select, checkboxSemantic names plus modifiersShort markup, theming
Headless UIUnstyled accessible form componentsYou write every styleAccessible React forms
Tailwind PlusCopy-paste form layoutsPrebuilt markup, one-time licenseExisting license holders only (closed to new customers since September 9, 2026)

A UI component library adds a dependency and its own markup conventions, which is the price of finished controls.

Two of the three official Tailwind Labs pieces still stack rather than compete. The plugin resets native controls, and Headless UI adds behavior and aria wiring. The third, Tailwind Plus, supplied styled layouts but is no longer sold to new customers.

The forms plugin itself has nothing to learn or uninstall, though no labels, errors or layouts come with it.

Flowbite has sizes, disabled inputs and floating labels ready to go, and its current docs target Tailwind v4 while saying it also works with v3.

daisyUI customizes components with Tailwind utilities, and its guidance is to append ! to a utility if specificity blocks an override. Version 5 requires Tailwind v4.

Headless UI v2.0 for React added 8 form components (Fieldset, Legend, Field, Label, Description, Input, Select, Textarea), and styling stays entirely yours.

Tailwind Plus is official, polished, copy-paste markup sold as a one-time purchase. Tailwind Labs closed sign-ups to new customers on September 9, 2026, when it announced it is joining Shopify, and existing customers keep access.

For a single login or contact form, a library is more than the job needs. The plugin plus a dozen utilities costs less to maintain.

Tailwind Forms FAQ

Is the forms plugin still needed in Tailwind v4?

Yes. The reset ships as a separate package, and the Tailwind Labs README still lists @tailwindcss/forms for v4, loaded with an @plugin line.

Without it, native controls keep their browser styling.

Is @tailwindcss/forms free to use?

Yes. The package carries the MIT license, so personal and commercial projects can use it at no cost.

Tailwind Plus form blocks were a separate paid, one-time purchase, and Tailwind Plus has been closed to new customers since September 9, 2026.

Where can I find free Tailwind form templates?

FormBold publishes an MIT-licensed GitHub repository of form templates and code examples written in plain Tailwind CSS, though it was last updated in 2022. Flowbite documents ready-made form components with copyable markup.

Check each license before reusing the markup in client work.

How do I style form inputs for dark mode?

Stack dark: variants on the same element, the way Tailwind’s documentation writes dark:disabled:border-gray-700 next to the light classes.

In v4, a class-based toggle needs a custom variant rule, written as @custom-variant dark with a selector for your dark class.

How do I build a floating label input?

Mark the input with the peer class, give it a single-space placeholder, and move the label with peer-focus and peer-placeholder-shown variants.

Tailwind’s documentation notes that peer variants make floating labels possible without any JavaScript.

Does the forms plugin work with React Hook Form and Formik?

Yes. The plugin is CSS only, so it styles the native inputs those libraries render.

Validation state comes from the library, which then drives error classes or aria-invalid on the field.

What Handles Submission and Validation Once Styling Is Done?

Tailwind forms stops at styling. Validation rules live in native HTML constraint attributes, form state in a library such as React Hook Form or Formik, and the final check on the server.

The order of those layers decides how much code a form needs. Start with the required, type and pattern attributes, bring in a form library once fields depend on each other, and validate on the server before anything is stored.

Attributes come first because invalid: and user-invalid styling read their results directly. A library adds a dependency, so it waits until field logic demands it. The server check stays last because a browser rule does not bind a direct request.

One annoyance remains. The browser draws its own native validation bubbles, and they take no utility classes, so custom error text means JavaScript-driven messages.

Verified October 2026: the plugin’s README still frames it as a reset. A move into component styling would change this split.