Tailwind Input: 16 Examples of Fields, States and Add-Ons

Tailwind has no input component. A field is a plain input tag, styled entirely by classes, states included.

Most confusion starts with Preflight, which sets border: 0 solid on every element and a transparent background on form controls, so a bare input shows no box. Below are 16 Tailwind input examples you can copy, followed by a guide to states, validation and v3 to v4 changes.

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>

Sizes, states and validation

The single text field, taken apart: three sizes driven by padding, one class list that covers every state, the difference between invalid: and user-invalid:, and a read-only key field in dark mode.

Three sizes driven by padding

  • px-* py-*
  • text-base sm:text-sm
  • aria-describedby
  • focus-visible:outline-*
  • placeholder:

Click or Tab into a field to see the focus outline.

No h-* class anywhere: padding plus the line height of text-sm, text-base or text-lg sets each height, so changing the font size resizes the field. The default size uses text-base sm:text-sm, which keeps the font at 16px on phones so iOS Safari does not zoom in on focus. The help text is tied to the field with aria-describedby.

View code
<div class="w-full max-w-lg rounded-2xl bg-white p-6 shadow-sm ring-1 ring-stone-900/10 sm:p-8">
  <h2 class="text-lg font-semibold tracking-tight text-stone-900">Workspace address</h2>
  <p class="mt-1 text-sm text-stone-600">The same field at three sizes. Only the padding, radius and font size change.</p>
  <div class="mt-6 space-y-5">
    <div>
      <div class="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
        <label for="tin-sz-sm" class="text-sm font-medium text-stone-800">Small</label>
        <code class="font-mono text-xs text-stone-600">px-2.5 py-1.5 text-sm</code>
      </div>
      <input id="tin-sz-sm" type="text" autocomplete="off" placeholder="harbor-and-pine" class="mt-1.5 block w-full rounded-md border border-stone-500 bg-white px-2.5 py-1.5 text-sm text-stone-900 placeholder:text-stone-500 focus-visible:border-emerald-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-emerald-700">
    </div>
    <div>
      <div class="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
        <label for="tin-sz-md" class="text-sm font-medium text-stone-800">Default</label>
        <code class="font-mono text-xs text-stone-600">px-3 py-2 text-base sm:text-sm</code>
      </div>
      <input id="tin-sz-md" type="text" autocomplete="off" placeholder="harbor-and-pine" aria-describedby="tin-sz-md-help" class="mt-1.5 block w-full rounded-lg border border-stone-500 bg-white px-3 py-2 text-base text-stone-900 placeholder:text-stone-500 focus-visible:border-emerald-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-emerald-700 sm:text-sm">
      <p id="tin-sz-md-help" class="mt-1.5 text-sm text-stone-600">Lowercase letters, numbers and hyphens. It appears in every invite link.</p>
    </div>
    <div>
      <div class="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
        <label for="tin-sz-lg" class="text-sm font-medium text-stone-800">Large</label>
        <code class="font-mono text-xs text-stone-600">px-4 py-3 text-lg</code>
      </div>
      <input id="tin-sz-lg" type="text" autocomplete="off" placeholder="harbor-and-pine" class="mt-1.5 block w-full rounded-xl border border-stone-500 bg-white px-4 py-3 text-lg text-stone-900 placeholder:text-stone-500 focus-visible:border-emerald-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-emerald-700">
    </div>
  </div>
</div>

One class list, six states

  • hover:
  • focus-visible:ring-4
  • disabled:
  • aria-[invalid=true]:
  • data-valid:

Tab through the fields and hover them.

All six fields carry the identical class list, and the attribute on each one picks the state: disabled, aria-invalid="true" or data-valid. The built-in aria variants do not include invalid, so the error styles use the bracket form aria-[invalid=true]:. Focus draws a soft 4px ring plus a solid border, with focus-visible:outline-hidden keeping a transparent outline for forced colors mode.

View code
<div class="w-full max-w-2xl rounded-2xl bg-white p-6 shadow-sm ring-1 ring-zinc-900/10 sm:p-8">
  <h2 class="text-lg font-semibold tracking-tight text-zinc-900">Display name field, every state</h2>
  <p class="mt-1 text-sm text-zinc-600">One shared class list. The attribute in grey under each field is the only difference.</p>
  <div class="mt-6 grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2">
    <div>
      <label for="tin-st-default" class="block text-sm font-medium text-zinc-800">Default</label>
      <input id="tin-st-default" type="text" placeholder="Rosa Lindqvist" class="mt-1.5 block w-full rounded-lg border border-zinc-500 bg-white px-3 py-2 text-base text-zinc-900 shadow-xs placeholder:text-zinc-500 hover:border-zinc-700 focus-visible:border-violet-600 focus-visible:ring-4 focus-visible:ring-violet-600/20 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:border-zinc-300 disabled:bg-zinc-100 disabled:text-zinc-600 disabled:shadow-none aria-[invalid=true]:border-red-600 aria-[invalid=true]:focus-visible:ring-red-600/20 data-valid:border-emerald-600 sm:text-sm">
      <p class="mt-1.5 font-mono text-xs text-zinc-600">no attribute</p>
    </div>
    <div>
      <label for="tin-st-focus" class="block text-sm font-medium text-zinc-800">Focus</label>
      <input id="tin-st-focus" type="text" placeholder="Click or Tab here" class="mt-1.5 block w-full rounded-lg border border-zinc-500 bg-white px-3 py-2 text-base text-zinc-900 shadow-xs placeholder:text-zinc-500 hover:border-zinc-700 focus-visible:border-violet-600 focus-visible:ring-4 focus-visible:ring-violet-600/20 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:border-zinc-300 disabled:bg-zinc-100 disabled:text-zinc-600 disabled:shadow-none aria-[invalid=true]:border-red-600 aria-[invalid=true]:focus-visible:ring-red-600/20 data-valid:border-emerald-600 sm:text-sm">
      <p class="mt-1.5 font-mono text-xs text-zinc-600">:focus-visible</p>
    </div>
    <div>
      <label for="tin-st-filled" class="block text-sm font-medium text-zinc-800">Filled</label>
      <input id="tin-st-filled" type="text" value="Rosa Lindqvist" class="mt-1.5 block w-full rounded-lg border border-zinc-500 bg-white px-3 py-2 text-base text-zinc-900 shadow-xs placeholder:text-zinc-500 hover:border-zinc-700 focus-visible:border-violet-600 focus-visible:ring-4 focus-visible:ring-violet-600/20 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:border-zinc-300 disabled:bg-zinc-100 disabled:text-zinc-600 disabled:shadow-none aria-[invalid=true]:border-red-600 aria-[invalid=true]:focus-visible:ring-red-600/20 data-valid:border-emerald-600 sm:text-sm">
      <p class="mt-1.5 font-mono text-xs text-zinc-600">value="..."</p>
    </div>
    <div>
      <label for="tin-st-disabled" class="block text-sm font-medium text-zinc-800">Disabled</label>
      <input id="tin-st-disabled" type="text" value="Rosa Lindqvist" disabled class="mt-1.5 block w-full rounded-lg border border-zinc-500 bg-white px-3 py-2 text-base text-zinc-900 shadow-xs placeholder:text-zinc-500 hover:border-zinc-700 focus-visible:border-violet-600 focus-visible:ring-4 focus-visible:ring-violet-600/20 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:border-zinc-300 disabled:bg-zinc-100 disabled:text-zinc-600 disabled:shadow-none aria-[invalid=true]:border-red-600 aria-[invalid=true]:focus-visible:ring-red-600/20 data-valid:border-emerald-600 sm:text-sm">
      <p class="mt-1.5 font-mono text-xs text-zinc-600">disabled</p>
    </div>
    <div>
      <label for="tin-st-invalid" class="block text-sm font-medium text-zinc-800">Invalid</label>
      <input id="tin-st-invalid" type="text" value="R" aria-invalid="true" aria-describedby="tin-st-invalid-msg" class="mt-1.5 block w-full rounded-lg border border-zinc-500 bg-white px-3 py-2 text-base text-zinc-900 shadow-xs placeholder:text-zinc-500 hover:border-zinc-700 focus-visible:border-violet-600 focus-visible:ring-4 focus-visible:ring-violet-600/20 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:border-zinc-300 disabled:bg-zinc-100 disabled:text-zinc-600 disabled:shadow-none aria-[invalid=true]:border-red-600 aria-[invalid=true]:focus-visible:ring-red-600/20 data-valid:border-emerald-600 sm:text-sm">
      <p id="tin-st-invalid-msg" class="mt-1.5 flex items-start gap-1.5 text-sm text-red-700"><svg class="mt-0.5 size-4 shrink-0" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M8 4.75v3.75M8 11v.25"/></svg>Use at least 2 characters.</p>
    </div>
    <div>
      <label for="tin-st-valid" class="block text-sm font-medium text-zinc-800">Valid</label>
      <input id="tin-st-valid" type="text" value="Rosa L." data-valid aria-describedby="tin-st-valid-msg" class="mt-1.5 block w-full rounded-lg border border-zinc-500 bg-white px-3 py-2 text-base text-zinc-900 shadow-xs placeholder:text-zinc-500 hover:border-zinc-700 focus-visible:border-violet-600 focus-visible:ring-4 focus-visible:ring-violet-600/20 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:border-zinc-300 disabled:bg-zinc-100 disabled:text-zinc-600 disabled:shadow-none aria-[invalid=true]:border-red-600 aria-[invalid=true]:focus-visible:ring-red-600/20 data-valid:border-emerald-600 sm:text-sm">
      <p id="tin-st-valid-msg" class="mt-1.5 flex items-start gap-1.5 text-sm text-emerald-800"><svg class="mt-0.5 size-4 shrink-0" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m3.5 8.5 3 3 6-7"/></svg>Shown on your public profile.</p>
    </div>
  </div>
</div>

invalid: next to user-invalid:

  • invalid:
  • user-invalid:
  • peer-user-invalid:
  • pattern
  • aria-invalid

Type a code with lowercase letters into the right field, then press Tab.

Both fields have the same required and pattern attributes. The left one uses invalid:, so it is red before anyone types; the right one uses user-invalid:, which waits until the value has been edited and the field left, or a submit was attempted. The error text is a peer sibling, and a short script keeps aria-invalid and aria-describedby in step so screen readers hear what the eye sees.

View code
<form class="tin-iv-form w-full max-w-2xl rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-900/10 sm:p-8">
  <h2 class="text-lg font-semibold tracking-tight text-slate-900">Redeem a gift code</h2>
  <p class="mt-1 text-sm text-slate-600">Codes are 8 capital letters or digits, like K7PQ2M9X.</p>
  <div class="mt-6 grid grid-cols-1 gap-6 sm:grid-cols-2">
    <div>
      <div class="flex flex-wrap items-baseline justify-between gap-x-3">
        <label for="tin-iv-now" class="text-sm font-medium text-slate-800">Gift code</label>
        <code class="font-mono text-xs text-slate-600">invalid:</code>
      </div>
      <div class="relative mt-1.5">
        <input id="tin-iv-now" type="text" required pattern="[A-Z0-9]{8}" maxlength="8" autocomplete="off" spellcheck="false" aria-describedby="tin-iv-now-msg" class="peer block w-full rounded-lg border border-slate-500 bg-white px-3.5 py-2.5 font-mono text-base tracking-widest text-slate-900 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-indigo-600 invalid:border-red-600 invalid:bg-red-50 invalid:focus-visible:outline-red-600">
        <p id="tin-iv-now-msg" class="mt-1.5 hidden text-sm text-red-700 peer-invalid:block">Enter all 8 characters.</p>
      </div>
    </div>
    <div>
      <div class="flex flex-wrap items-baseline justify-between gap-x-3">
        <label for="tin-iv-user" class="text-sm font-medium text-slate-800">Gift code</label>
        <code class="font-mono text-xs text-slate-600">user-invalid:</code>
      </div>
      <div class="relative mt-1.5">
        <input id="tin-iv-user" type="text" required pattern="[A-Z0-9]{8}" maxlength="8" autocomplete="off" spellcheck="false" aria-describedby="tin-iv-user-help" class="tin-iv-live peer block w-full rounded-lg border border-slate-500 bg-white py-2.5 pr-10 pl-3.5 font-mono text-base tracking-widest text-slate-900 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-indigo-600 user-valid:border-emerald-700 user-invalid:border-red-600 user-invalid:bg-red-50 user-invalid:focus-visible:outline-red-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-red-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="tin-iv-user-help" class="mt-1.5 text-sm text-slate-600 peer-user-invalid:hidden">Printed on the back of the card.</p>
        <p id="tin-iv-user-msg" class="mt-1.5 hidden text-sm text-red-700 peer-user-invalid:block">Use 8 capital letters or digits, no spaces.</p>
      </div>
    </div>
  </div>
  <div class="mt-6 flex flex-wrap items-center gap-x-4 gap-y-2">
    <button type="submit" class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Redeem</button>
    <p id="tin-iv-status" class="text-sm font-medium text-emerald-800 empty:hidden" role="status"></p>
  </div>
</form>

Read-only API key in dark mode

  • dark:
  • read-only:
  • selection:
  • caret-*
  • disabled:

Click the key, press Copy, then type in the webhook field.

Every color has a light value and a dark: partner, so the same markup works on a light page; this stage puts .dark on the html element. The key field is readonly, so read-only: gives it a recessed fill while it can still be focused, selected and submitted, unlike the disabled field below it. selection: and caret-* color the highlighted text and the text cursor, and the copy button selects the key and copies it, or asks for Ctrl+C when the browser blocks the copy.

View code
<div class="w-full max-w-xl rounded-2xl bg-white p-6 shadow-sm ring-1 ring-zinc-900/10 sm:p-8 dark:bg-zinc-900 dark:shadow-none dark:ring-white/10">
  <h2 class="text-lg font-semibold tracking-tight text-zinc-900 dark:text-white">API access</h2>
  <p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">Use the test key in staging. It never charges a card.</p>
  <div class="mt-6 space-y-5">
    <div>
      <label for="tin-dk-key" class="block text-sm font-medium text-zinc-800 dark:text-zinc-200">Test secret key</label>
      <div class="mt-1.5 flex gap-2">
        <input id="tin-dk-key" type="text" readonly value="lume_test_7Qm2Xv9R4kD1pZ8sA3nT" spellcheck="false" aria-describedby="tin-dk-key-help" class="block w-full min-w-0 rounded-lg border border-zinc-400 bg-white px-3 py-2.5 font-mono text-sm text-zinc-900 selection:bg-lime-300 selection:text-zinc-950 read-only:cursor-text read-only:border-dashed read-only:bg-zinc-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-lime-600 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:read-only:bg-zinc-800/60 dark:focus-visible:outline-lime-400">
        <button type="button" id="tin-dk-copy" class="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-zinc-300 bg-white px-3 text-sm font-semibold text-zinc-800 hover:bg-zinc-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lime-600 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700 dark:focus-visible:outline-lime-400">
          <svg class="size-4" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" aria-hidden="true"><rect x="7" y="7" width="10" height="10" rx="2"/><path d="M13 7V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"/></svg>
          Copy
        </button>
      </div>
      <p id="tin-dk-key-help" class="mt-1.5 text-sm text-zinc-600 dark:text-zinc-400">Read-only. Rotate it from the security tab if it leaks.</p>
      <p id="tin-dk-status" class="mt-1 text-sm font-medium text-lime-800 empty:hidden dark:text-lime-300" role="status"></p>
    </div>
    <div>
      <label for="tin-dk-hook" class="block text-sm font-medium text-zinc-800 dark:text-zinc-200">Webhook URL</label>
      <input id="tin-dk-hook" type="url" placeholder="https&#58;//hooks.example.com/lume" spellcheck="false" class="mt-1.5 block w-full rounded-lg border border-zinc-400 bg-white px-3 py-2.5 font-mono text-sm text-zinc-900 caret-lime-600 placeholder:text-zinc-500 selection:bg-lime-300 selection:text-zinc-950 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-lime-600 user-invalid:border-red-600 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:caret-lime-400 dark:placeholder:text-zinc-500 dark:focus-visible:outline-lime-400 dark:user-invalid:border-red-400">
    </div>
    <div>
      <label for="tin-dk-sign" class="block text-sm font-medium text-zinc-500 dark:text-zinc-400">Signing secret</label>
      <input id="tin-dk-sign" type="text" disabled value="Available on the Team plan" aria-describedby="tin-dk-sign-help" class="mt-1.5 block w-full rounded-lg border border-zinc-400 bg-white px-3 py-2.5 font-mono text-sm text-zinc-900 disabled:cursor-not-allowed disabled:border-zinc-200 disabled:bg-zinc-50 disabled:text-zinc-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:disabled:border-zinc-800 dark:disabled:bg-zinc-900 dark:disabled:text-zinc-500">
      <p id="tin-dk-sign-help" class="mt-1.5 text-sm text-zinc-600 dark:text-zinc-400">Disabled fields skip the tab order and are not submitted.</p>
    </div>
  </div>
</div>

Floating labels, inset labels and add-ons

Where the label and the extras sit: a label that docks on the border, labels inside the box, a leading icon with an attached button, and prefix and suffix add-ons for prices and URLs.

Floating label that docks on the border

  • peer-placeholder-shown:
  • peer-focus:
  • placeholder=" "
  • dark:
  • motion-reduce:

Click a field and type.

Each input has placeholder=" " and the class peer, and its label comes after it in the markup. While the placeholder shows, peer-placeholder-shown: centers the label inside the box; on focus or once there is text, the label sits on the top edge, and a background matching the card cuts the border behind it. Every color has a dark: partner, and the movement turns off with motion-reduce:transition-none.

View code
<form class="tin-fb-form w-full max-w-md rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-900/10 sm:p-8 dark:bg-slate-900 dark:shadow-none dark:ring-white/10">
  <h2 class="text-lg font-semibold tracking-tight text-slate-900 dark:text-white">Book a walkthrough</h2>
  <p class="mt-1 text-sm text-slate-600 dark:text-slate-400">A 20 minute call with someone from the Orbitline team.</p>
  <div class="mt-7 space-y-6">
    <div class="relative">
      <input id="tin-fb-name" type="text" placeholder=" " autocomplete="name" required class="peer block w-full rounded-lg border border-slate-400 bg-transparent px-3.5 py-3 text-base text-slate-900 focus-visible:border-sky-600 focus-visible:outline-1 focus-visible:-outline-offset-2 focus-visible:outline-sky-600 dark:border-slate-600 dark:text-white dark:focus-visible:border-sky-400 dark:focus-visible:outline-sky-400">
      <label for="tin-fb-name" class="pointer-events-none absolute top-0 left-2.5 -translate-y-1/2 bg-white px-1 text-xs font-medium text-sky-700 transition-all duration-150 peer-placeholder-shown:top-1/2 peer-placeholder-shown:text-base peer-placeholder-shown:font-normal peer-placeholder-shown:text-slate-600 peer-focus:top-0 peer-focus:text-xs peer-focus:font-medium peer-focus:text-sky-700 motion-reduce:transition-none dark:bg-slate-900 dark:text-sky-300 dark:peer-placeholder-shown:text-slate-400 dark:peer-focus:text-sky-300">Full name</label>
    </div>
    <div class="relative">
      <input id="tin-fb-email" type="email" placeholder=" " autocomplete="email" required class="peer block w-full rounded-lg border border-slate-400 bg-transparent px-3.5 py-3 text-base text-slate-900 focus-visible:border-sky-600 focus-visible:outline-1 focus-visible:-outline-offset-2 focus-visible:outline-sky-600 dark:border-slate-600 dark:text-white dark:focus-visible:border-sky-400 dark:focus-visible:outline-sky-400">
      <label for="tin-fb-email" class="pointer-events-none absolute top-0 left-2.5 -translate-y-1/2 bg-white px-1 text-xs font-medium text-sky-700 transition-all duration-150 peer-placeholder-shown:top-1/2 peer-placeholder-shown:text-base peer-placeholder-shown:font-normal peer-placeholder-shown:text-slate-600 peer-focus:top-0 peer-focus:text-xs peer-focus:font-medium peer-focus:text-sky-700 motion-reduce:transition-none dark:bg-slate-900 dark:text-sky-300 dark:peer-placeholder-shown:text-slate-400 dark:peer-focus:text-sky-300">Work email</label>
    </div>
    <div>
      <div class="relative">
        <input id="tin-fb-team" type="text" inputmode="numeric" placeholder=" " aria-describedby="tin-fb-team-help" class="peer block w-full rounded-lg border border-slate-400 bg-transparent px-3.5 py-3 text-base text-slate-900 focus-visible:border-sky-600 focus-visible:outline-1 focus-visible:-outline-offset-2 focus-visible:outline-sky-600 dark:border-slate-600 dark:text-white dark:focus-visible:border-sky-400 dark:focus-visible:outline-sky-400">
        <label for="tin-fb-team" class="pointer-events-none absolute top-0 left-2.5 -translate-y-1/2 bg-white px-1 text-xs font-medium text-sky-700 transition-all duration-150 peer-placeholder-shown:top-1/2 peer-placeholder-shown:text-base peer-placeholder-shown:font-normal peer-placeholder-shown:text-slate-600 peer-focus:top-0 peer-focus:text-xs peer-focus:font-medium peer-focus:text-sky-700 motion-reduce:transition-none dark:bg-slate-900 dark:text-sky-300 dark:peer-placeholder-shown:text-slate-400 dark:peer-focus:text-sky-300">Team size</label>
      </div>
      <p id="tin-fb-team-help" class="mt-1.5 text-sm text-slate-600 dark:text-slate-400">Optional. Helps us pick the right plan to show.</p>
    </div>
  </div>
  <button type="submit" class="mt-7 w-full rounded-lg bg-sky-700 px-4 py-3 text-base font-semibold text-white hover:bg-sky-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700 dark:bg-sky-400 dark:text-slate-950 dark:hover:bg-sky-300 dark:focus-visible:outline-sky-400">Pick a time</button>
  <p id="tin-fb-status" class="mt-3 text-sm font-medium text-emerald-800 empty:hidden dark:text-emerald-300" role="status"></p>
</form>

Inset labels and a merged address group

  • focus-within:ring-2
  • ring-inset
  • focus-within:z-10
  • -mt-px
  • focus:outline-hidden

Tab through the address fields.

The label sits inside the box: the wrapper div draws the border with ring-1 ring-inset and switches to a 2px ring with focus-within:, while the bare input inside has no border or padding of its own. In the merged group, -mt-px and -ml-px overlap the neighbouring rings and focus-within:z-10 lifts the focused cell so its whole ring stays visible.

View code
<form class="tin-il-form w-full max-w-md rounded-2xl bg-[#fbf7f2] p-6 shadow-sm ring-1 ring-stone-900/10 sm:p-8">
  <h2 class="text-lg font-semibold tracking-tight text-stone-900">Where should the beans go?</h2>
  <p class="mt-1 text-sm text-stone-600">Bramble Roasters ships every other Tuesday.</p>
  <div class="mt-6 rounded-lg bg-white px-3 pt-2.5 pb-1.5 ring-1 ring-stone-400 ring-inset focus-within:ring-2 focus-within:ring-amber-800">
    <label for="tin-il-name" class="block text-xs font-medium text-stone-700">Full name</label>
    <input id="tin-il-name" type="text" autocomplete="name" placeholder="Ines Moreau" class="block w-full border-0 bg-transparent p-0 text-base text-stone-900 placeholder:text-stone-500 focus:outline-hidden sm:text-sm">
  </div>
  <fieldset class="mt-5">
    <legend class="text-sm font-medium text-stone-800">Delivery address</legend>
    <div class="mt-2">
      <div class="relative rounded-t-lg bg-white px-3 pt-2.5 pb-1.5 ring-1 ring-stone-400 ring-inset focus-within:z-10 focus-within:ring-2 focus-within:ring-amber-800">
        <label for="tin-il-street" class="block text-xs font-medium text-stone-700">Street and number</label>
        <input id="tin-il-street" type="text" autocomplete="address-line1" placeholder="14 Quarry Lane" class="block w-full border-0 bg-transparent p-0 text-base text-stone-900 placeholder:text-stone-500 focus:outline-hidden sm:text-sm">
      </div>
      <div class="-mt-px grid grid-cols-[3fr_2fr]">
        <div class="relative rounded-bl-lg bg-white px-3 pt-2.5 pb-1.5 ring-1 ring-stone-400 ring-inset focus-within:z-10 focus-within:ring-2 focus-within:ring-amber-800">
          <label for="tin-il-city" class="block text-xs font-medium text-stone-700">City</label>
          <input id="tin-il-city" type="text" autocomplete="address-level2" placeholder="Ashford" class="block w-full min-w-0 border-0 bg-transparent p-0 text-base text-stone-900 placeholder:text-stone-500 focus:outline-hidden sm:text-sm">
        </div>
        <div class="relative -ml-px rounded-br-lg bg-white px-3 pt-2.5 pb-1.5 ring-1 ring-stone-400 ring-inset focus-within:z-10 focus-within:ring-2 focus-within:ring-amber-800">
          <label for="tin-il-zip" class="block text-xs font-medium text-stone-700">Postcode</label>
          <input id="tin-il-zip" type="text" autocomplete="postal-code" placeholder="TN24 0AB" class="block w-full min-w-0 border-0 bg-transparent p-0 text-base text-stone-900 uppercase placeholder:text-stone-500 placeholder:normal-case focus:outline-hidden sm:text-sm">
        </div>
      </div>
    </div>
  </fieldset>
  <div class="mt-5 rounded-lg bg-white px-3 pt-2.5 pb-1.5 ring-1 ring-stone-400 ring-inset focus-within:ring-2 focus-within:ring-amber-800">
    <label for="tin-il-note" class="block text-xs font-medium text-stone-700">Note for the courier</label>
    <input id="tin-il-note" type="text" placeholder="Leave it in the porch" aria-describedby="tin-il-note-help" class="block w-full border-0 bg-transparent p-0 text-base text-stone-900 placeholder:text-stone-500 focus:outline-hidden sm:text-sm">
  </div>
  <p id="tin-il-note-help" class="mt-1.5 text-sm text-stone-600">Optional, up to 80 characters.</p>
  <button type="submit" class="mt-6 w-full rounded-lg bg-amber-800 px-4 py-3 text-base font-semibold text-white hover:bg-amber-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-800">Save address</button>
  <p id="tin-il-status" class="mt-3 text-sm font-medium text-emerald-800 empty:hidden" role="status"></p>
</form>

Leading icon and attached button

  • has-[input:focus-visible]:
  • min-w-0 flex-1
  • pointer-events-none
  • <template>
  • sr-only

Type an email address and press Send invite.

The border lives on a flex wrapper holding the icon, the input and the button, and has-[input:focus-visible]: draws one outline around all three only when the text field has focus; the button keeps its own focus style. min-w-0 flex-1 lets the input shrink on phones instead of pushing the button out. New rows are cloned from a template element, so the script never builds markup from strings.

View code
<div class="w-full max-w-lg rounded-2xl bg-white p-6 shadow-sm ring-1 ring-teal-950/10 sm:p-8">
  <h2 class="text-lg font-semibold tracking-tight text-slate-900">Invite your team</h2>
  <p class="mt-1 text-sm text-slate-600">Each invite link expires after 7 days.</p>
  <form class="tin-ib-form mt-5">
    <label for="tin-ib-email" class="block text-sm font-medium text-slate-800">Email address</label>
    <div class="mt-1.5 flex rounded-lg border border-slate-500 bg-white has-[input:focus-visible]:border-teal-700 has-[input:focus-visible]:outline-2 has-[input:focus-visible]:-outline-offset-1 has-[input:focus-visible]:outline-teal-700">
      <div class="pointer-events-none grid shrink-0 place-items-center pl-3 text-slate-500">
        <svg class="size-5" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" aria-hidden="true"><rect x="2.5" y="4.5" width="15" height="11" rx="2"/><path d="m3 5.5 7 5 7-5"/></svg>
      </div>
      <input id="tin-ib-email" type="email" required autocomplete="off" placeholder="name@example.com" class="min-w-0 flex-1 bg-transparent py-2.5 pr-2 pl-2.5 text-base text-slate-900 placeholder:text-slate-500 focus:outline-hidden sm:text-sm">
      <button type="submit" class="m-1 shrink-0 rounded-md bg-teal-700 px-3.5 text-sm font-semibold text-white hover:bg-teal-800 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-teal-700">Send invite</button>
    </div>
  </form>
  <p id="tin-ib-status" class="mt-2 text-sm text-slate-700 empty:hidden" role="status"></p>
  <h3 class="mt-6 text-sm font-semibold text-slate-900">Pending invites</h3>
  <ul id="tin-ib-list" role="list" class="mt-2 divide-y divide-slate-200 border-y border-slate-200">
    <li class="tin-ib-row flex items-center gap-3 py-3">
      <span class="grid size-9 shrink-0 place-items-center rounded-full bg-teal-100 text-xs font-semibold text-teal-900" aria-hidden="true">MO</span>
      <span class="tin-ib-addr min-w-0 flex-1 truncate text-sm text-slate-800">maya.okafor@example.com</span>
      <span class="hidden rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-800 ring-1 ring-amber-600/20 ring-inset sm:inline">Pending</span>
      <button type="button" class="tin-ib-revoke rounded-md px-2 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-2 focus-visible:outline-teal-700" aria-label="Revoke invite for maya.okafor@example.com">Revoke</button>
    </li>
    <li class="tin-ib-row flex items-center gap-3 py-3">
      <span class="grid size-9 shrink-0 place-items-center rounded-full bg-teal-100 text-xs font-semibold text-teal-900" aria-hidden="true">JB</span>
      <span class="tin-ib-addr min-w-0 flex-1 truncate text-sm text-slate-800">jonas.berg@example.com</span>
      <span class="hidden rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-800 ring-1 ring-amber-600/20 ring-inset sm:inline">Pending</span>
      <button type="button" class="tin-ib-revoke rounded-md px-2 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-2 focus-visible:outline-teal-700" aria-label="Revoke invite for jonas.berg@example.com">Revoke</button>
    </li>
  </ul>
  <template id="tin-ib-tpl">
    <li class="tin-ib-row flex items-center gap-3 py-3">
      <span class="tin-ib-initials grid size-9 shrink-0 place-items-center rounded-full bg-teal-100 text-xs font-semibold text-teal-900" aria-hidden="true"></span>
      <span class="tin-ib-addr min-w-0 flex-1 truncate text-sm text-slate-800"></span>
      <span class="hidden rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-800 ring-1 ring-amber-600/20 ring-inset sm:inline">Pending</span>
      <button type="button" class="tin-ib-revoke rounded-md px-2 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-2 focus-visible:outline-teal-700">Revoke</button>
    </li>
  </template>
</div>

Prefix and suffix add-ons

  • has-[input:focus-visible]:
  • inputmode="decimal"
  • border-l
  • tabular-nums
  • sm:grid-cols-2

Type a price like 12.5 and move to the next field.

Each add-on is a plain span in the same flex wrapper as the input, so it never covers the text and needs no absolute positioning. Inline symbols like $ and % are light text inside the box, while boxed add-ons such as https:// get a fill and a divider line. inputmode="decimal" brings up the number keypad on phones without the quirks of type="number", and a blur handler tidies the price to two decimals.

View code
<form class="tin-ad-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-lg font-semibold tracking-tight text-slate-900">Product and store settings</h2>
  <p class="mt-1 text-sm text-slate-600">Tidemark Goods, enamel camping mug.</p>
  <div class="mt-6 grid grid-cols-1 gap-5 sm:grid-cols-2">
    <div>
      <label for="tin-ad-price" class="block text-sm font-medium text-slate-800">Price</label>
      <div class="mt-1.5 flex items-center rounded-lg border border-slate-500 bg-white has-[input:focus-visible]:border-orange-700 has-[input:focus-visible]:outline-2 has-[input:focus-visible]:-outline-offset-1 has-[input:focus-visible]:outline-orange-700">
        <span class="pl-3 text-base text-slate-600 select-none sm:text-sm" aria-hidden="true">$</span>
        <input id="tin-ad-price" type="text" inputmode="decimal" pattern="[0-9]+([.][0-9]{1,2})?" value="18.00" aria-describedby="tin-ad-price-cur" class="min-w-0 flex-1 bg-transparent py-2.5 pr-2 pl-1 text-base text-slate-900 tabular-nums focus:outline-hidden sm:text-sm">
        <span id="tin-ad-price-cur" class="self-stretch content-center rounded-r-lg border-l border-slate-300 bg-slate-50 px-3 text-sm font-medium text-slate-700">USD</span>
      </div>
    </div>
    <div>
      <label for="tin-ad-off" class="block text-sm font-medium text-slate-800">Launch discount</label>
      <div class="mt-1.5 flex items-center rounded-lg border border-slate-500 bg-white has-[input:focus-visible]:border-orange-700 has-[input:focus-visible]:outline-2 has-[input:focus-visible]:-outline-offset-1 has-[input:focus-visible]:outline-orange-700">
        <input id="tin-ad-off" type="text" inputmode="numeric" pattern="[0-9]{1,2}" value="15" aria-describedby="tin-ad-off-unit" class="min-w-0 flex-1 bg-transparent py-2.5 pr-1 pl-3 text-base text-slate-900 tabular-nums focus:outline-hidden sm:text-sm">
        <span id="tin-ad-off-unit" class="pr-3 text-base text-slate-600 select-none sm:text-sm">percent off</span>
      </div>
    </div>
    <div class="sm:col-span-2">
      <label for="tin-ad-site" class="block text-sm font-medium text-slate-800">Website</label>
      <div class="mt-1.5 flex rounded-lg border border-slate-500 bg-white has-[input:focus-visible]:border-orange-700 has-[input:focus-visible]:outline-2 has-[input:focus-visible]:-outline-offset-1 has-[input:focus-visible]:outline-orange-700">
        <span class="content-center rounded-l-lg border-r border-slate-300 bg-slate-50 px-3 text-sm text-slate-700 select-none">https&#58;//</span>
        <input id="tin-ad-site" type="text" autocomplete="url" spellcheck="false" placeholder="tidemark.example.com" class="min-w-0 flex-1 bg-transparent px-3 py-2.5 text-base text-slate-900 placeholder:text-slate-500 focus:outline-hidden sm:text-sm">
      </div>
    </div>
    <div class="sm:col-span-2">
      <label for="tin-ad-sub" class="block text-sm font-medium text-slate-800">Store address</label>
      <div class="mt-1.5 flex rounded-lg border border-slate-500 bg-white has-[input:focus-visible]:border-orange-700 has-[input:focus-visible]:outline-2 has-[input:focus-visible]:-outline-offset-1 has-[input:focus-visible]:outline-orange-700">
        <input id="tin-ad-sub" type="text" spellcheck="false" value="tidemark" aria-describedby="tin-ad-sub-suffix tin-ad-sub-help" class="min-w-0 flex-1 bg-transparent px-3 py-2.5 text-right text-base text-slate-900 focus:outline-hidden sm:text-sm">
        <span id="tin-ad-sub-suffix" class="content-center rounded-r-lg border-l border-slate-300 bg-slate-50 px-3 text-sm text-slate-700 select-none">.mercato.example</span>
      </div>
      <p id="tin-ad-sub-help" class="mt-1.5 text-sm text-slate-600">Changing it breaks links you already shared.</p>
    </div>
  </div>
</form>

Password, code, tag and number inputs

Fields that need a few lines of JavaScript on top of the utilities: a password reveal with a Caps Lock warning, one-time code boxes with paste, a chip input and a number stepper that flags out-of-range values.

Password reveal with a Caps Lock warning

  • group/field
  • group-data-caps/field:
  • group-aria-pressed/eye:
  • getModifierState
  • empty:hidden

Type a password, press the eye button, try Caps Lock.

Two named groups keep the states apart: the script sets data-caps on the field wrapper when getModifierState reports Caps Lock, and group-data-caps/field: turns the border amber, while group-aria-pressed/eye: swaps the eye icons inside the toggle. The toggle keeps one label and reports its state with aria-pressed, as the APG toggle button pattern describes, and the warning lands in a status region.

View code
<form class="tin-pw-form w-full max-w-md rounded-2xl bg-white p-6 shadow-sm ring-1 ring-blue-950/10 sm:p-8">
  <h2 class="text-lg font-semibold tracking-tight text-slate-900">Set a new password</h2>
  <p class="mt-1 text-sm text-slate-600">Ledgerly signs you out on every other device after the change.</p>
  <div id="tin-pw-field" class="group/field mt-6">
    <label for="tin-pw-input" class="block text-sm font-medium text-slate-800">New password</label>
    <div class="relative mt-1.5">
      <input id="tin-pw-input" type="password" required minlength="12" autocomplete="new-password" aria-describedby="tin-pw-help tin-pw-caps" class="block w-full rounded-lg border border-slate-500 bg-white py-2.5 pr-12 pl-3.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 group-data-caps/field:border-amber-600 group-data-caps/field:focus-visible:outline-amber-600">
      <button type="button" id="tin-pw-toggle" aria-pressed="false" aria-label="Show password" aria-controls="tin-pw-input" class="group/eye absolute inset-y-0 right-0 grid w-12 place-items-center rounded-r-lg text-slate-600 hover:text-slate-900 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-700">
        <svg class="size-5 group-aria-pressed/eye: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/eye: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>
    <p id="tin-pw-caps" role="status" class="mt-2 flex items-center gap-1.5 text-sm font-medium text-amber-800 empty:hidden"></p>
    <p id="tin-pw-help" class="mt-2 text-sm text-slate-600">Use 12 or more characters. Four random words make a strong passphrase.</p>
  </div>
  <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">Change password</button>
  <p id="tin-pw-status" class="mt-3 text-sm font-medium text-emerald-800 empty:hidden" role="status"></p>
</form>

One-time code boxes with paste

  • not-placeholder-shown:
  • caret-*
  • group-data-[state=ok]:
  • inputmode="numeric"
  • tabular-nums

Type six digits, or paste a code like 482916.

Six single-character inputs inside a fieldset, each with its own aria-label. A placeholder dot marks empty slots, and not-placeholder-shown: darkens a box once it holds a digit, with no script involved. The script moves focus forward on input and back on Backspace, spreads a pasted or autofilled code across the boxes, and sets data-state on the fieldset so group-data-[state=ok]: turns every box green.

View code
<div class="w-full max-w-md rounded-2xl bg-white p-5 shadow-sm ring-1 ring-zinc-900/10 sm:p-8">
  <h2 class="text-lg font-semibold tracking-tight text-zinc-900">Check your inbox</h2>
  <p class="mt-1 text-sm text-zinc-600">We sent a 6-digit code to ma&#8226;&#8226;&#8226;@example.com. It works for 10 minutes.</p>
  <fieldset id="tin-otp" class="group mt-6">
    <legend class="sr-only">Verification code</legend>
    <div class="flex items-center justify-center gap-1.5 sm:gap-2.5">
      <input type="text" inputmode="numeric" autocomplete="one-time-code" aria-label="Digit 1 of 6" placeholder="&#183;" class="tin-otp-box h-12 w-10 rounded-lg border border-zinc-400 bg-white text-center text-2xl font-semibold text-zinc-900 tabular-nums caret-rose-600 placeholder:text-zinc-400 not-placeholder-shown:border-zinc-800 focus-visible:border-rose-600 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-rose-600 disabled:bg-zinc-50 group-data-[state=ok]:border-emerald-600 group-data-[state=ok]:bg-emerald-50 group-data-[state=ok]:text-emerald-900 sm:h-14 sm:w-12">
      <input type="text" inputmode="numeric" autocomplete="off" aria-label="Digit 2 of 6" placeholder="&#183;" class="tin-otp-box h-12 w-10 rounded-lg border border-zinc-400 bg-white text-center text-2xl font-semibold text-zinc-900 tabular-nums caret-rose-600 placeholder:text-zinc-400 not-placeholder-shown:border-zinc-800 focus-visible:border-rose-600 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-rose-600 disabled:bg-zinc-50 group-data-[state=ok]:border-emerald-600 group-data-[state=ok]:bg-emerald-50 group-data-[state=ok]:text-emerald-900 sm:h-14 sm:w-12">
      <input type="text" inputmode="numeric" autocomplete="off" aria-label="Digit 3 of 6" placeholder="&#183;" class="tin-otp-box h-12 w-10 rounded-lg border border-zinc-400 bg-white text-center text-2xl font-semibold text-zinc-900 tabular-nums caret-rose-600 placeholder:text-zinc-400 not-placeholder-shown:border-zinc-800 focus-visible:border-rose-600 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-rose-600 disabled:bg-zinc-50 group-data-[state=ok]:border-emerald-600 group-data-[state=ok]:bg-emerald-50 group-data-[state=ok]:text-emerald-900 sm:h-14 sm:w-12">
      <span class="hidden h-0.5 w-3 rounded bg-zinc-400 sm:block" aria-hidden="true"></span>
      <input type="text" inputmode="numeric" autocomplete="off" aria-label="Digit 4 of 6" placeholder="&#183;" class="tin-otp-box h-12 w-10 rounded-lg border border-zinc-400 bg-white text-center text-2xl font-semibold text-zinc-900 tabular-nums caret-rose-600 placeholder:text-zinc-400 not-placeholder-shown:border-zinc-800 focus-visible:border-rose-600 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-rose-600 disabled:bg-zinc-50 group-data-[state=ok]:border-emerald-600 group-data-[state=ok]:bg-emerald-50 group-data-[state=ok]:text-emerald-900 sm:h-14 sm:w-12">
      <input type="text" inputmode="numeric" autocomplete="off" aria-label="Digit 5 of 6" placeholder="&#183;" class="tin-otp-box h-12 w-10 rounded-lg border border-zinc-400 bg-white text-center text-2xl font-semibold text-zinc-900 tabular-nums caret-rose-600 placeholder:text-zinc-400 not-placeholder-shown:border-zinc-800 focus-visible:border-rose-600 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-rose-600 disabled:bg-zinc-50 group-data-[state=ok]:border-emerald-600 group-data-[state=ok]:bg-emerald-50 group-data-[state=ok]:text-emerald-900 sm:h-14 sm:w-12">
      <input type="text" inputmode="numeric" autocomplete="off" aria-label="Digit 6 of 6" placeholder="&#183;" class="tin-otp-box h-12 w-10 rounded-lg border border-zinc-400 bg-white text-center text-2xl font-semibold text-zinc-900 tabular-nums caret-rose-600 placeholder:text-zinc-400 not-placeholder-shown:border-zinc-800 focus-visible:border-rose-600 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-rose-600 disabled:bg-zinc-50 group-data-[state=ok]:border-emerald-600 group-data-[state=ok]:bg-emerald-50 group-data-[state=ok]:text-emerald-900 sm:h-14 sm:w-12">
    </div>
  </fieldset>
  <p id="tin-otp-status" role="status" class="mt-4 min-h-5 text-center text-sm font-medium text-zinc-700 data-[state=ok]:text-emerald-800"></p>
  <p class="mt-3 text-center text-sm text-zinc-600">No email? <button type="button" id="tin-otp-resend" class="rounded font-semibold text-rose-700 underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-rose-600">Send a new code</button></p>
</div>

Tag input with removable chips

  • flex-wrap
  • has-[input:focus-visible]:
  • display: contents
  • ring-inset
  • <template>

Type a topic and press Enter, or Backspace to remove one.

The chips and the text field share one flex-wrap container that looks like the input; the list uses the contents utility so its items flow inline with the field, with role="list" to keep list semantics. Enter or a comma adds a tag, Backspace in the empty field removes the last one, and each chip has a remove button with its own label. A status region announces every change.

View code
<div class="w-full max-w-lg rounded-2xl bg-white p-6 shadow-sm ring-1 ring-neutral-900/10 sm:p-8">
  <h2 class="text-lg font-semibold tracking-tight text-neutral-900">Article topics</h2>
  <p class="mt-1 text-sm text-neutral-600">Readers can follow topics, so pick the ones they would search for.</p>
  <label for="tin-tg-input" class="mt-6 block text-sm font-medium text-neutral-800">Topics</label>
  <div id="tin-tg-box" class="mt-1.5 flex min-h-11 cursor-text flex-wrap items-center gap-1.5 rounded-lg border border-neutral-500 bg-white p-1.5 has-[input:focus-visible]:border-fuchsia-700 has-[input:focus-visible]:outline-2 has-[input:focus-visible]:-outline-offset-1 has-[input:focus-visible]:outline-fuchsia-700">
    <ul id="tin-tg-list" role="list" class="contents">
      <li class="tin-tg-chip inline-flex items-center gap-0.5 rounded-md bg-fuchsia-50 py-0.5 pr-0.5 pl-2.5 text-sm font-medium text-fuchsia-900 ring-1 ring-fuchsia-700/20 ring-inset"><span class="tin-tg-text">Typography</span><button type="button" class="tin-tg-remove grid size-7 place-items-center rounded text-fuchsia-700 hover:bg-fuchsia-100 hover:text-fuchsia-900 focus-visible:outline-2 focus-visible:outline-fuchsia-700" aria-label="Remove Typography"><svg class="size-3.5" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="m3.5 3.5 7 7m0-7-7 7"/></svg></button></li>
      <li class="tin-tg-chip inline-flex items-center gap-0.5 rounded-md bg-fuchsia-50 py-0.5 pr-0.5 pl-2.5 text-sm font-medium text-fuchsia-900 ring-1 ring-fuchsia-700/20 ring-inset"><span class="tin-tg-text">Color theory</span><button type="button" class="tin-tg-remove grid size-7 place-items-center rounded text-fuchsia-700 hover:bg-fuchsia-100 hover:text-fuchsia-900 focus-visible:outline-2 focus-visible:outline-fuchsia-700" aria-label="Remove Color theory"><svg class="size-3.5" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="m3.5 3.5 7 7m0-7-7 7"/></svg></button></li>
    </ul>
    <input id="tin-tg-input" type="text" autocomplete="off" enterkeyhint="enter" aria-describedby="tin-tg-help tin-tg-count" placeholder="Add a topic" class="min-w-32 flex-1 bg-transparent px-1.5 py-1 text-base text-neutral-900 placeholder:text-neutral-500 focus:outline-hidden sm:text-sm">
  </div>
  <div class="mt-1.5 flex flex-wrap justify-between gap-x-4 text-sm text-neutral-600">
    <p id="tin-tg-help">Press Enter or a comma to add.</p>
    <p id="tin-tg-count" class="tabular-nums">2 of 6 topics</p>
  </div>
  <p id="tin-tg-status" role="status" class="sr-only"></p>
  <template id="tin-tg-tpl">
    <li class="tin-tg-chip inline-flex items-center gap-0.5 rounded-md bg-fuchsia-50 py-0.5 pr-0.5 pl-2.5 text-sm font-medium text-fuchsia-900 ring-1 ring-fuchsia-700/20 ring-inset"><span class="tin-tg-text"></span><button type="button" class="tin-tg-remove grid size-7 place-items-center rounded text-fuchsia-700 hover:bg-fuchsia-100 hover:text-fuchsia-900 focus-visible:outline-2 focus-visible:outline-fuchsia-700"><svg class="size-3.5" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="m3.5 3.5 7 7m0-7-7 7"/></svg></button></li>
  </template>
</div>

Number stepper with range warnings

  • @theme
  • [appearance:textfield]
  • [&::-webkit-inner-spin-button]:appearance-none
  • has-[input:out-of-range]:
  • disabled:

Use the plus and minus buttons, or type 900 pages.

The native spin buttons are hidden with [appearance:textfield] and the two -webkit-*-spin-button arbitrary variants, and real buttons call stepUp() and stepDown(), so min, max and step stay in the markup. A typed value outside min and max matches :out-of-range, which group-has-[input:out-of-range]: uses to color the box and reveal the message. The ink, paper and rust colors are @theme tokens with the tin prefix.

View code
<form class="tin-sp-form w-full max-w-md rounded-2xl bg-tin-paper p-6 text-tin-ink shadow-sm ring-1 ring-tin-ink/10 sm:p-8">
  <h2 class="text-lg font-semibold tracking-tight text-tin-ink">Print the zine</h2>
  <p class="mt-1 text-sm text-tin-ink/80">Risograph, two colors, saddle stitched. 6 cents a page.</p>
  <div class="mt-6 space-y-5">
    <div class="tin-sp group">
      <label for="tin-sp-copies" class="block text-sm font-medium">Copies</label>
      <div class="mt-1.5 flex w-full max-w-60 items-stretch rounded-lg border border-tin-ink/60 bg-white group-has-[input:out-of-range]:border-tin-rust group-has-[input:out-of-range]:bg-red-50">
        <button type="button" class="tin-sp-dec grid w-11 shrink-0 place-items-center rounded-l-lg text-tin-ink hover:bg-tin-ink/5 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tin-rust disabled:cursor-not-allowed disabled:text-tin-ink/30 disabled:hover:bg-transparent" aria-label="Fewer copies"><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.5 8h9"/></svg></button>
        <input id="tin-sp-copies" type="number" min="10" max="500" step="10" value="50" aria-describedby="tin-sp-copies-help" class="tin-sp-input min-w-0 flex-1 border-x border-tin-ink/20 bg-transparent py-2.5 text-center text-base font-semibold tabular-nums [appearance:textfield] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tin-rust [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none">
        <button type="button" class="tin-sp-inc grid w-11 shrink-0 place-items-center rounded-r-lg text-tin-ink hover:bg-tin-ink/5 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tin-rust disabled:cursor-not-allowed disabled:text-tin-ink/30 disabled:hover:bg-transparent" aria-label="More copies"><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.5 8h9M8 3.5v9"/></svg></button>
      </div>
      <p id="tin-sp-copies-help" class="mt-1.5 text-sm text-tin-ink/80 group-has-[input:out-of-range]:font-medium group-has-[input:out-of-range]:text-tin-rust">10 to 500, in steps of 10.</p>
    </div>
    <div class="tin-sp group">
      <label for="tin-sp-pages" class="block text-sm font-medium">Pages per copy</label>
      <div class="mt-1.5 flex w-full max-w-60 items-stretch rounded-lg border border-tin-ink/60 bg-white group-has-[input:out-of-range]:border-tin-rust group-has-[input:out-of-range]:bg-red-50">
        <button type="button" class="tin-sp-dec grid w-11 shrink-0 place-items-center rounded-l-lg text-tin-ink hover:bg-tin-ink/5 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tin-rust disabled:cursor-not-allowed disabled:text-tin-ink/30 disabled:hover:bg-transparent" aria-label="Fewer pages"><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.5 8h9"/></svg></button>
        <input id="tin-sp-pages" type="number" min="8" max="64" step="4" value="24" aria-describedby="tin-sp-pages-help" class="tin-sp-input min-w-0 flex-1 border-x border-tin-ink/20 bg-transparent py-2.5 text-center text-base font-semibold tabular-nums [appearance:textfield] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tin-rust [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none">
        <button type="button" class="tin-sp-inc grid w-11 shrink-0 place-items-center rounded-r-lg text-tin-ink hover:bg-tin-ink/5 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tin-rust disabled:cursor-not-allowed disabled:text-tin-ink/30 disabled:hover:bg-transparent" aria-label="More pages"><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.5 8h9M8 3.5v9"/></svg></button>
      </div>
      <p id="tin-sp-pages-help" class="mt-1.5 text-sm text-tin-ink/80 group-has-[input:out-of-range]:font-medium group-has-[input:out-of-range]:text-tin-rust">8 to 64, in multiples of 4 so the sheets fold.</p>
    </div>
  </div>
  <div class="mt-6 flex items-baseline justify-between gap-4 border-t border-dashed border-tin-ink/30 pt-4">
    <span class="text-sm font-medium">Estimate</span>
    <output id="tin-sp-total" for="tin-sp-copies tin-sp-pages" aria-live="polite" class="text-xl font-semibold tabular-nums">$72.00</output>
  </div>
</form>

Textarea, color, date and file inputs

Native controls with their own internals: an auto-growing textarea, a color picker synced with a hex field, date and time fields with styled picker icons, and file inputs dressed with the file: variant.

Auto-growing textarea with a character counter

  • field-sizing-content
  • min-h-* max-h-*
  • resize-none
  • data-[state=over]:
  • disabled:

Type a few lines of text.

field-sizing-content lets the textarea grow with its text between min-h-20 and max-h-60, then scroll; where the property is not supported, a three-line fallback sets the height from scrollHeight. The counter is linked with aria-describedby and switches color through data-[state=warn]: and data-[state=over]:, while a separate status region only speaks near and past the limit instead of on every key.

View code
<div class="w-full max-w-xl rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-900/10 sm:p-6">
  <div class="flex gap-3">
    <svg class="size-9 shrink-0" viewBox="0 0 36 36" aria-hidden="true"><circle cx="18" cy="18" r="18" fill="#cffafe"/><text x="18" y="22.5" text-anchor="middle" font-size="13" font-weight="600" fill="#155e75">DW</text></svg>
    <div class="min-w-0 flex-1">
      <p class="text-sm"><span class="font-semibold text-slate-900">Dana Whitfield</span> <span class="text-slate-600">2 hours ago</span></p>
      <p class="mt-1 text-sm text-slate-700">The new pricing page reads well. Could we cut the FAQ down to the five questions people actually ask?</p>
    </div>
  </div>
  <form class="tin-ta-form mt-5 flex gap-3">
    <svg class="size-9 shrink-0" viewBox="0 0 36 36" aria-hidden="true"><circle cx="18" cy="18" r="18" fill="#e2e8f0"/><text x="18" y="22.5" text-anchor="middle" font-size="13" font-weight="600" fill="#334155">RK</text></svg>
    <div class="min-w-0 flex-1">
      <label for="tin-ta-text" class="sr-only">Reply to Dana</label>
      <textarea id="tin-ta-text" rows="2" placeholder="Write a reply" aria-describedby="tin-ta-count" class="block max-h-60 min-h-20 w-full resize-none rounded-lg border border-slate-500 bg-white px-3 py-2 text-base text-slate-900 field-sizing-content placeholder:text-slate-500 focus-visible:border-cyan-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-cyan-700 sm:text-sm"></textarea>
      <div class="mt-2 flex items-center justify-between gap-3">
        <p id="tin-ta-count" class="text-sm text-slate-600 tabular-nums data-[state=over]:font-semibold data-[state=over]:text-red-700 data-[state=warn]:text-amber-800">0 of 280 characters</p>
        <button type="submit" id="tin-ta-send" disabled class="rounded-lg bg-cyan-700 px-4 py-2 text-sm font-semibold text-white hover:bg-cyan-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cyan-700 disabled:cursor-not-allowed disabled:bg-slate-200 disabled:text-slate-600">Reply</button>
      </div>
      <p id="tin-ta-status" role="status" class="sr-only"></p>
    </div>
  </form>
</div>

Color input synced with a hex field

  • [&::-webkit-color-swatch]:
  • [&::-moz-color-swatch]:
  • bg-(--tin-accent)
  • user-invalid:
  • aria-pressed

Pick a preset, use the picker, or type a hex code.

The native color input keeps its picker, and arbitrary variants on ::-webkit-color-swatch-wrapper, ::-webkit-color-swatch and ::-moz-color-swatch strip the default inset so the swatch fills a rounded box. The hex field and the picker update each other, and the script writes the color to a --tin-accent custom property that bg-(--tin-accent) reads. The preview reports the WCAG contrast ratio of white text on the chosen color.

View code
<div id="tin-cl" class="w-full max-w-lg rounded-2xl bg-white p-6 shadow-sm ring-1 ring-neutral-900/10 [--tin-accent:#0f766e] sm:p-8">
  <h2 class="text-lg font-semibold tracking-tight text-neutral-900">Brand accent</h2>
  <p class="mt-1 text-sm text-neutral-600">Used for buttons, links and focus rings across your help center.</p>
  <label for="tin-cl-hex" class="mt-6 block text-sm font-medium text-neutral-800">Hex value</label>
  <div class="mt-1.5 flex gap-2">
    <input id="tin-cl-picker" type="color" value="#0f766e" aria-label="Pick a color" class="h-11 w-14 shrink-0 cursor-pointer rounded-lg border border-neutral-500 bg-white p-1 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900 [&::-moz-color-swatch]:rounded-md [&::-moz-color-swatch]:border-0 [&::-webkit-color-swatch]:rounded-md [&::-webkit-color-swatch]:border-0 [&::-webkit-color-swatch-wrapper]:p-0">
    <div class="flex min-w-0 flex-1 items-center rounded-lg border border-neutral-500 bg-white has-[input:focus-visible]:outline-2 has-[input:focus-visible]:-outline-offset-1 has-[input:focus-visible]:outline-neutral-900 has-[input:user-invalid]:border-red-600">
      <span class="pl-3 font-mono text-base text-neutral-600 select-none sm:text-sm" aria-hidden="true">#</span>
      <input id="tin-cl-hex" type="text" value="0f766e" maxlength="7" pattern="#?[0-9a-fA-F]{6}" spellcheck="false" autocomplete="off" aria-describedby="tin-cl-help" class="min-w-0 flex-1 bg-transparent py-2.5 pr-3 pl-1 font-mono text-base text-neutral-900 uppercase focus:outline-hidden sm:text-sm">
    </div>
  </div>
  <p id="tin-cl-help" class="mt-1.5 text-sm text-neutral-600">Six hex digits, with or without the #.</p>
  <div class="mt-4 flex flex-wrap gap-2" role="group" aria-label="Presets">
    <button type="button" class="tin-cl-preset size-8 rounded-full bg-[#0f766e] ring-offset-2 aria-pressed:ring-2 aria-pressed:ring-neutral-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900" data-hex="#0f766e" aria-pressed="true" aria-label="Pine #0F766E"></button>
    <button type="button" class="tin-cl-preset size-8 rounded-full bg-[#1d4ed8] ring-offset-2 aria-pressed:ring-2 aria-pressed:ring-neutral-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900" data-hex="#1d4ed8" aria-pressed="false" aria-label="Cobalt #1D4ED8"></button>
    <button type="button" class="tin-cl-preset size-8 rounded-full bg-[#be123c] ring-offset-2 aria-pressed:ring-2 aria-pressed:ring-neutral-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900" data-hex="#be123c" aria-pressed="false" aria-label="Rosewood #BE123C"></button>
    <button type="button" class="tin-cl-preset size-8 rounded-full bg-[#7c3aed] ring-offset-2 aria-pressed:ring-2 aria-pressed:ring-neutral-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900" data-hex="#7c3aed" aria-pressed="false" aria-label="Violet #7C3AED"></button>
    <button type="button" class="tin-cl-preset size-8 rounded-full bg-[#f59e0b] ring-offset-2 aria-pressed:ring-2 aria-pressed:ring-neutral-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900" data-hex="#f59e0b" aria-pressed="false" aria-label="Marigold #F59E0B"></button>
  </div>
  <div class="mt-6 rounded-xl border border-neutral-200 bg-neutral-50 p-4">
    <div class="flex flex-wrap items-center gap-3">
      <span class="rounded-lg bg-(--tin-accent) px-4 py-2.5 text-sm font-semibold text-white">Start free trial</span>
      <span class="text-sm font-semibold text-(--tin-accent) underline underline-offset-4">Read the docs</span>
    </div>
    <p id="tin-cl-ratio" aria-live="polite" class="mt-3 text-sm text-neutral-700 data-[pass=false]:font-medium data-[pass=false]:text-red-700">White text: 5.47:1, passes AA.</p>
  </div>
</div>

Date and time fields with styled picker icons

  • scheme-dark
  • [&::-webkit-calendar-picker-indicator]:
  • invalid:
  • peer-invalid:
  • setCustomValidity

Change the end time to 09:00, then to 12:30.

scheme-dark sets color-scheme: dark, so the native picker popups, the clock and calendar icons and the empty segments all match the dark card. Arbitrary variants on ::-webkit-calendar-picker-indicator change the icon cursor and opacity in Chrome and Safari; the popup itself cannot be styled. The script calls setCustomValidity when the end time is not after the start, which makes invalid: and peer-invalid: show the error.

View code
<form class="tin-dt-form w-full max-w-xl rounded-2xl bg-slate-900 p-6 ring-1 ring-white/10 sm:p-8">
  <h2 class="text-lg font-semibold tracking-tight text-white">Book Studio B</h2>
  <p class="mt-1 text-sm text-slate-400">Soundproofed room with a grand piano. Bookings run in 15 minute steps.</p>
  <div class="mt-6 grid grid-cols-1 gap-5 sm:grid-cols-3">
    <div>
      <label for="tin-dt-date" class="block text-sm font-medium text-slate-200">Date</label>
      <input id="tin-dt-date" type="date" required class="mt-1.5 block w-full rounded-lg border border-slate-600 bg-slate-950 px-3 py-2.5 text-base text-white scheme-dark focus-visible:border-amber-300 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-amber-300 sm:text-sm [&::-webkit-calendar-picker-indicator]:cursor-pointer [&::-webkit-calendar-picker-indicator]:rounded [&::-webkit-calendar-picker-indicator]:opacity-70 [&::-webkit-calendar-picker-indicator]:hover:opacity-100">
    </div>
    <div>
      <label for="tin-dt-start" class="block text-sm font-medium text-slate-200">From</label>
      <input id="tin-dt-start" type="time" required step="900" value="10:00" class="mt-1.5 block w-full rounded-lg border border-slate-600 bg-slate-950 px-3 py-2.5 text-base text-white scheme-dark focus-visible:border-amber-300 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-amber-300 sm:text-sm [&::-webkit-calendar-picker-indicator]:cursor-pointer [&::-webkit-calendar-picker-indicator]:rounded [&::-webkit-calendar-picker-indicator]:opacity-70 [&::-webkit-calendar-picker-indicator]:hover:opacity-100">
    </div>
    <div>
      <label for="tin-dt-end" class="block text-sm font-medium text-slate-200">To</label>
      <input id="tin-dt-end" type="time" required step="900" value="12:00" aria-describedby="tin-dt-end-msg" class="peer mt-1.5 block w-full rounded-lg border border-slate-600 bg-slate-950 px-3 py-2.5 text-base text-white scheme-dark focus-visible:border-amber-300 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-amber-300 invalid:border-red-400 invalid:focus-visible:outline-red-400 sm:text-sm [&::-webkit-calendar-picker-indicator]:cursor-pointer [&::-webkit-calendar-picker-indicator]:rounded [&::-webkit-calendar-picker-indicator]:opacity-70 [&::-webkit-calendar-picker-indicator]:hover:opacity-100">
      <p id="tin-dt-end-msg" class="mt-1.5 hidden text-sm text-red-300 peer-invalid:block">End after the start time.</p>
    </div>
  </div>
  <div class="mt-6 flex flex-col gap-4 border-t border-white/10 pt-5 sm:flex-row sm:items-center sm:justify-between">
    <output id="tin-dt-summary" for="tin-dt-date tin-dt-start tin-dt-end" aria-live="polite" class="text-sm text-slate-300">Pick a date</output>
    <button type="submit" class="rounded-lg bg-amber-300 px-4 py-2.5 text-sm font-semibold text-slate-950 hover:bg-amber-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-300">Book studio</button>
  </div>
</form>

File inputs styled with the file: variant

  • file:
  • hover:file:
  • file:border-r
  • accept
  • multiple

Choose one or more files in the last field.

file: targets the ::file-selector-button inside the input, so the button gets its own padding, color and radius while the filename text stays native. hover:file: changes the button when the whole input is hovered. The third field adds a script that lists each chosen file with its size and flags anything over the 10 MB limit.

View code
<form class="tin-fi-form w-full max-w-lg rounded-2xl bg-white p-6 shadow-sm ring-1 ring-pink-950/10 sm:p-8">
  <h2 class="text-lg font-semibold tracking-tight text-slate-900">Speaker kit</h2>
  <p class="mt-1 text-sm text-slate-600">Send us what we need for the Fieldnotes 2026 program.</p>
  <div class="mt-6 space-y-6">
    <div>
      <label for="tin-fi-photo" class="block text-sm font-medium text-slate-800">Headshot</label>
      <input id="tin-fi-photo" type="file" accept="image/png, image/jpeg" class="mt-2 block w-full text-sm text-slate-600 file:mr-4 file:cursor-pointer file:rounded-full file:border-0 file:bg-pink-50 file:px-4 file:py-2.5 file:text-sm file:font-semibold file:text-pink-800 hover:file:bg-pink-100 focus-visible:rounded-full focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-pink-700">
    </div>
    <div>
      <label for="tin-fi-slides" class="block text-sm font-medium text-slate-800">Slides</label>
      <input id="tin-fi-slides" type="file" accept=".pdf,application/pdf" aria-describedby="tin-fi-slides-help" class="mt-1.5 block w-full cursor-pointer rounded-lg border border-slate-500 bg-white text-sm text-slate-700 file:mr-3 file:cursor-pointer file:border-0 file:border-r file:border-slate-300 file:bg-slate-100 file:px-4 file:py-2.5 file:font-medium file:text-slate-800 hover:file:bg-slate-200 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-pink-700">
      <p id="tin-fi-slides-help" class="mt-1.5 text-sm text-slate-600">PDF only, so fonts survive the projector laptop.</p>
    </div>
    <div>
      <label for="tin-fi-extra" class="block text-sm font-medium text-slate-800">Extra material</label>
      <input id="tin-fi-extra" type="file" multiple aria-describedby="tin-fi-extra-help" class="mt-1.5 block w-full cursor-pointer rounded-lg border border-dashed border-slate-500 bg-slate-50 p-2 text-sm text-slate-700 file:mr-3 file:cursor-pointer file:rounded-md file:border-0 file:bg-slate-900 file:px-3.5 file:py-2 file:text-sm file:font-semibold file:text-white hover:file:bg-slate-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-pink-700">
      <p id="tin-fi-extra-help" class="mt-1.5 text-sm text-slate-600">Up to 10 MB per file.</p>
      <ul id="tin-fi-list" role="list" class="mt-3 space-y-1.5 empty:hidden"></ul>
      <p id="tin-fi-status" role="status" class="mt-2 text-sm text-slate-700 empty:hidden"></p>
    </div>
  </div>
  <template id="tin-fi-tpl">
    <li class="tin-fi-row flex items-center justify-between gap-3 rounded-md bg-slate-100 px-3 py-2 text-sm data-big:bg-red-50">
      <span class="tin-fi-name min-w-0 truncate font-medium text-slate-800"></span>
      <span class="tin-fi-size shrink-0 text-slate-600 tabular-nums in-data-big:font-medium in-data-big:text-red-700"></span>
    </li>
  </template>
</form>

What Is a Tailwind Input?

Underneath the classes, it’s the same input tag the browser has always had. The Tailwind CSS framework only supplies utilities and variants.

That leaves the HTML markup intact, so type, required, pattern and minlength attributes work exactly as they do without Tailwind.

Ready-made versions exist. Tailwind Labs sold input groups and form layouts inside Tailwind Plus as a one-time purchase until it closed to new customers on September 9, 2026, and libraries such as daisyUI, Flowbite, shadcn/ui and Material Tailwind ship their own input components built on the same utilities.

By the time an input renders, several things have touched it. The browser’s default styles come first, then Preflight, then the @tailwindcss/forms plugin if it’s installed. After that come the utility classes on the element itself, and last the variant classes for focus, disabled, invalid and dark mode.

Most “why does my input have no border” questions trace back to the second step.

Why Does an Input Look Unstyled in Tailwind?

Preflight does it. Tailwind injects it through @import “tailwindcss”, and it sets border: 0 solid on every element while giving form controls a transparent background, an inherited font and color, and zero border radius. A bare input shows no visible box until utility classes draw one.

ApproachWhat a bare input getsScope
Preflight onlyNo border, transparent background, inherited fontEvery element, automatic
Forms plugin, base strategyA simple, consistent field styleGlobal, 17 element selectors
Forms plugin, class strategyThe same baseline, only where form-* classes sitOpt-in per element

What Preflight Removes

Preflight is built on modern-normalize and loads in the base layer, so utility classes always win over it.

  • Every element gets border: 0 solid, which is why the border class adds a solid 1px line in currentColor (v4)
  • Input, select, textarea and button lose their background and go transparent
  • Font and color are inherited from the parent instead of the browser default
  • Radius drops to 0 on every form control

Dropping the preflight.css import disables the whole reset, which is the usual route when Tailwind joins an existing project (Tailwind CSS documentation, v4.3).

What the Forms Plugin Restores

The plugin resets things. It does not hand you components.

As of October 2026, the Tailwind Labs README lists 17 element selectors: 12 text, date and time input types, plus checkbox, radio, select, select[multiple] and textarea. Each one gets a simple style that utilities override.

Text fields rarely need it. Checkboxes, radios and selects do, because the plugin turns them into elements that accept padding and text color utilities.

  • On Tailwind v4, run npm install -D @tailwindcss/forms, then add @plugin “@tailwindcss/forms”; to the main stylesheet after @import “tailwindcss”;
  • On v3, add require(‘@tailwindcss/forms’) to the plugins array in tailwind.config.js

The strategy option limits the output. Base generates global styles only, while class generates form-input, form-textarea, form-select, form-multiselect, form-checkbox and form-radio and leaves every other element alone.

One documented side effect: since version 0.5.4, inputs with no type attribute receive the styles too. A GitHub issue (#142) asked for a per-input opt-out, and a maintainer declined, saying it was not a goal for now and citing the complexity the typography plugin’s equivalent had accumulated.

Laying out several styled fields in one form is a separate job, covered in this guide to building complete forms in Tailwind.

How to Build a Base Text Input with Utility Classes

Start with the label and the bare input, then layer classes on in the order below.

Base Classes Step by Step

  1. Add a label with for set to email, and an input with id email, name email and type email.
  2. Set the width with w-full and cap it with max-w-sm so the field does not stretch across a wide container.
  3. Add padding with px-3 py-2.
  4. Add border border-gray-300 rounded-md. Name the color, because v4 draws borders in currentColor by default.
  5. Add bg-white, since Preflight leaves the background transparent.
  6. Add text-sm text-gray-900 placeholder:text-gray-400.

The finished class list reads w-full max-w-sm rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400

Matching for and id values let a screen reader announce the label when the field takes focus, which is the baseline of accessible form practice.

Paste the class list into Tailwind Play to check the result in the browser before wiring it into a project.

Sizing and Width

Let padding set the height instead of an h-* class. Changing text-sm to text-base then resizes the field with no other edit.

Component libraries don’t agree on fixed heights anyway, and David UI and Material Tailwind each list different pixel heights for their small, medium and large inputs.

Pair w-full with a max-w-* wrapper so the field fills narrow screens and stops growing on wide ones.

Styling Hover, Disabled, Read-Only and Placeholder States

Put a prefix such as hover:, disabled:, read-only: or placeholder: in front of any utility and the class applies only while the input is in that state.

The same class list then covers every state, and the browser applies the right styles without conditional logic in the template, according to the Tailwind documentation.

  • hover only fires on devices that support it, since v4 wraps the variant in @media (hover: hover)
  • disabled matches the disabled attribute, as in disabled:bg-gray-50 disabled:text-gray-500
  • read-only matches the readonly attribute, and read-only:bg-gray-100 is a typical use
  • placeholder styles the placeholder text itself, for example placeholder:text-gray-500 placeholder:italic

Disabled and read-only look alike on screen but behave differently. A disabled field cannot take focus and is left out of form submission. Read-only is looser, since the field can still be focused and selected, and its value goes through on submit.

Placeholder color changed between versions. v4 renders placeholder text in the current text color at 50% opacity, where v3 used gray-400 (Tailwind upgrade guide), so set the color explicitly when the design depends on it.

Placeholder and disabled text still need enough color contrast to stay readable, which rules out the palest grays on a white background.

Dark mode follows the same pattern. The dark: variant targets prefers-color-scheme, and every color property needs its own dark counterpart: border, background, text, placeholder and ring.

v4 applies stacked variants left to right, so the classes read in the order the selector builds, as in dark:disabled:bg-gray-800/20.

How to Add or Remove the Focus Ring on a Tailwind Input

Replace the default outline instead of deleting it. In v4, add focus:outline-hidden plus a visible indicator such as focus:ring-2 focus:ring-blue-500. In v3, use focus:outline-none with the same ring classes.

Tailwind’s own documentation shows the shift. The v3-era input example used focus:outline-none with focus:ring-1, while the v4.3 example uses focus:outline focus:outline-sky-500.

CaseClassesNote
Single input, v3focus:outline-none focus:ring-2 focus:ring-blue-500ring-2 sets the width, ring-blue-500 the color
Single input, v4focus:outline-hidden focus:ring-2 focus:ring-blue-500Keeps the outline in forced colors mode
Input group wrapperfocus-within:outline-2 focus-within:outline-indigo-600, with outline-none on the inner fieldOne ring around icon, field and button

In v4, outline-none sets outline-style: none. outline-hidden sets a 2px transparent outline with a 2px offset, which stays visible when a user turns on forced colors mode (Tailwind outline-style documentation).

The default ring changed too. A bare ring class drew 3px in blue-500 in v3 and draws 1px in currentColor in v4, so a copied v3 snippet shows a thin ring in the text color. Use ring-2 or ring-3 with an explicit color.

Text inputs match both focus and focus-visible. The focus heuristics in the CSS Selectors Level 4 specification have text-entry elements match :focus-visible whether focus arrives by mouse or keyboard, and browsers follow them, so focus: and focus-visible: give the same result on a text field.

The difference shows on buttons and checkboxes, where focus-visible hides the ring after a mouse click. All major browsers support :focus-visible, so using it there carries no fallback cost.

The W3C classes 2.4.7 Focus Visible as a Level AA criterion: any keyboard-operable interface needs a mode where the focus indicator can be seen. MDN adds that the indicator needs at least 3:1 contrast under WCAG 1.4.11 Non-text Contrast.

Keyboard users rely on that indicator to know where they are in a form, which makes focus styling a baseline requirement for web accessibility.

WCAG 2.2 adds 2.4.13 Focus Appearance at Level AAA for guidance on indicator size and contrast, since 2.4.7 leaves the form of the indicator open.

How to Style Invalid and Valid Inputs

Use user-invalid:border-red-500 for feedback after the user has touched the field, or invalid:border-red-500 for instant feedback, then reveal the message with a peer sibling. Native attributes such as required, type=”email”, pattern and minlength decide which state applies, because Tailwind styles the result and validates nothing.

TechniqueFires whenCatch
invalid:The value fails a constraint, including on page loadA blank required field turns red before anyone types
user-invalid:The field fails after interaction or a submit attemptNeeds Tailwind v4.1 or later
invalid with not-placeholder-shownThe field fails and the placeholder is goneNeeds a placeholder attribute, misses failed submits
aria-[invalid=true]:Script sets aria-invalid to trueThe built-in aria variants skip invalid, so brackets are required

Tailwind added user-valid and user-invalid in v4.1 (release notes, April 2025). MDN’s Baseline data lists :user-invalid as Newly Available since November 2023 and Widely Available from May 2026, with support from Chrome 119, Firefox 88 and Safari 16.5.

Older guides delay the error with invalid:[&:not(:placeholder-shown)]. user-invalid: does that job with one class, and it also fires after a failed submit on an untouched required field, which the placeholder trick misses. I’d pick it every time on v4.1 or later.

For the error message, put the input first, mark it with peer, and give the message invisible peer-invalid:visible. Only a previous sibling can be a peer, so a message placed before the input never reacts (Tailwind documentation).

Screen readers need aria-describedby pointing at the message id so the text is read with the field. The ARIA attributes for errors are documented on MDN, where aria-invalid marks the state and aria-errormessage links the text.

The sources don’t fully agree here. MDN pairs aria-errormessage with aria-invalid=”true”, while some accessibility guides call aria-invalid redundant once aria-describedby carries the error text. The split comes from announcement: aria-errormessage only applies while a field is invalid, and descriptions have no such dependency.

Attach the message with aria-describedby, and add aria-invalid when a style rule needs an attribute to hook onto.

shadcn/ui takes the attribute route for script-driven state. Its Input docs mark a field with the aria-invalid prop and style the wrapping Field through data-invalid.

Libraries such as React Hook Form decide validity in JavaScript, so attribute hooks fit them better than :invalid.

Cross-field rules, like a “confirm password” check, need a few lines of JavaScript. Call setCustomValidity with a message on the confirmation input, and the invalid: styles apply on their own.

Reset the message to an empty string once the values match, because the form does not submit while any custom message remains (MDN).

Input Types and Layout Patterns in Tailwind

Text, email, password, tel, url, search, number and date inputs can share one class list. Checkbox, radio and range controls need accent and appearance utilities, and file inputs need the file: variant.

daisyUI draws the same line in its docs: the input class covers the text-like types, while checkbox, radio, file and range get their own class names.

Checkbox, Radio and Range

  • accent-pink-500 recolors the native checkbox, radio or range control, and the opacity modifier works only in Firefox (Tailwind documentation)
  • appearance-none strips the native styling so a custom box can use the checked: variant
  • forced-colors:appearance-auto hands the native control back to people in forced colors mode
  • has-checked on a wrapping label turns a radio into a selectable card

The Tailwind docs build their payment method picker this way, with has-checked:bg-indigo-50 and has-checked:ring-indigo-200 on the label. More variations live in this guide to radio buttons in Tailwind.

File, Search, Number and Date

The file: variant styles only the button inside a file input, as in file:rounded-full file:border-0 file:bg-violet-50 file:px-4 file:py-2. The filename text and the picker dialog stay native.

Search inputs carry a WebKit decoration, and Preflight already removes its inner padding in Chrome and Safari on macOS. A finished field with a button is its own pattern, shown in this build of a complete Tailwind search bar.

Number, date and time inputs reuse the base list. Preflight also keeps empty date and time fields at the same height in iOS Safari and corrects the cursor on the number spin buttons in Safari (preflight.css).

Icons, Labels and Helper Text

For an icon, wrap the field in a relative container, position the icon absolutely with pointer-events-none, and add left padding to the input so the text clears it. Labels usually get block text-sm font-medium, plus after:ml-0.5 after:text-red-500 after:content-[‘*’] when a required marker is wanted. Helper text is just a small gray paragraph under the field.

pointer-events-none lets a click on the icon land in the input, the same trick the Tailwind docs use for custom select arrows.

Input Groups and Floating Labels

An input group is a flex wrapper holding the field and a button. The input takes flex-1 min-w-0 so it can shrink, and only the outer corners keep their radius.

Floating labels take more setup. Put peer on the input, set its placeholder to a single space, and style the label with peer-placeholder-shown and peer-focus. Flowbite publishes three versions of this (standard, outlined and filled) with validation states and dark mode.

The label comes after the input in the markup, because only previous siblings work as peers. Drop the placeholder attribute and placeholder-shown never matches, so the label stays raised over the text.

What Changed for Inputs Between Tailwind v3 and v4?

Most v3 to v4 breakage on inputs comes from renamed scale steps, new defaults and a different build setup. A snippet copied from a v3 tutorial can render with another radius, shadow or outline until those are updated.

The figures below come from the Tailwind upgrade guide and the v4.3 docs.

  • The browser floor is Safari 16.4, Chrome 111 and Firefox 128, and older browsers stay on v3.4
  • The outline utility now sets 1px by default, and outline-* number utilities default to solid
  • The upgrade tool is npx @tailwindcss/upgrade, which needs Node.js 20 or higher
  • Steps were renamed, so rounded becomes rounded-sm, rounded-sm becomes rounded-xs, and shadow-sm becomes shadow-xs
  • v4.0 shipped in January 2025, and v4.1 added user-valid and user-invalid in April 2025

The bare versions of the renamed utilities still work for backward compatibility. A v3 field with rounded-sm shadow-sm looks different in v4 until those classes move to rounded-xs and shadow-xs, while rounded-md and the rest of the base list are unaffected.

Transitions caught people out as well. transition and transition-colors now include outline-color, so a focus outline with a custom color animates from the default color on first focus unless the color is set unconditionally.

The build setup moved too. The PostCSS plugin is now @tailwindcss/postcss, the CLI is @tailwindcss/cli, and Vite projects should use @tailwindcss/vite. Check that the stylesheet holding @plugin “@tailwindcss/forms” passes through one of them.

One gap sits between two sources. v4 targets Chrome 111 and Safari 16.4, while MDN dates :user-invalid support to Chrome 119 and Safari 16.5.

In those older versions the user-invalid: rules are ignored, so the field gets no red border from that class and only the browser’s own message on submit.

Hand-Built Utilities, Forms Plugin or Component Library: Which to Use?

Hand-build with utilities for one or two forms, add @tailwindcss/forms when checkboxes and selects must match across browsers, and adopt a component library when several developers need one shared input. The rows below run from least to most overhead.

ApproachWhat you getTrade-off
Utilities onlyFull control and no dependency beyond TailwindYou write and maintain every class list
@tailwindcss/formsOne baseline for text fields, checkboxes, radios and selectsNo labels, icons or validation layout
Headless UIInput, Field, Label and Description with generated IDs and state attributesNo styles, so the class lists are still yours
daisyUI, FlowbiteClass names for input sizes, colors and statesA second naming layer on top of utilities
shadcn/ui, Tailwind PlusFinished input source with labels, groups and layoutsYou own and update the code, and Tailwind Plus is paid

Reaching for a library feels like the obvious move, but for a few fields it adds more than it removes.

Tailwind Labs describes the Headless UI Input as a light wrapper around the native element. It exposes data-focus, data-hover, data-invalid and data-disabled, so a class like data-focus:bg-blue-100 styles each state, and Field links the label and description to the input for you.

With daisyUI everything sits behind an input class, with input-primary through input-error colors, sizes from input-xs to input-xl and a validator class for constraint styling. The look follows the active daisyUI theme.

shadcn/ui works differently, because the CLI command pnpm dlx shadcn@latest add input puts the component in your project. The Input file under components/ui is then yours to edit. Pair it with Field for labels, descriptions and error states.

Flowbite’s released code is MIT licensed (Flowbite license page), and its docs cover input fields, file inputs, search inputs and floating labels. Tailwind Plus sells input groups and form layouts, and the Tailwind Plus pricing page lists the current license cost.

A library earns its weight once a team maintains a design system with one input shared across products.

Where Tailwind Input Styling Stops Working

Tailwind styles the closed input box. Anything the browser draws outside it, such as the date picker popup or autofilled colors, sits out of reach or needs a workaround.

Chrome’s date input FAQ states that the picker appearance cannot be styled, because the popup is separate from the document and no standard covers styling its parts. Each browser draws its own, so a date field needs a cross-browser compatibility check instead of a class fix.

Matching a design takes a custom picker component.

Autofill is its own headache. Tailwind ships an autofill: variant, and its docs show autofill:bg-yellow-200, but MDN says authors cannot override the default background-color, background-image or color of a selected autofilled input in their own rules, because browser stylesheets mark them !important.

Browser engines differ in how they paint autofill. The usual workaround is an inset box-shadow in the field color, as CSS-Tricks documents.

On iPhones, Safari on iOS does not zoom into a focused input when its font size is 16px or larger (CSS-Tricks, citing Josh W. Comeau). The base list built earlier uses text-sm, so on phones swap it for text-base sm:text-sm.

Repeated class lists cost something too. The base list is 11 classes long, and every copy needs the same fix when a default changes. A v3 to v4 upgrade shows it, because the radius, outline and shadow edits land in every field.

Tailwind Input FAQ

How does a textarea or select differ from an input in Tailwind?

The same utility classes apply, because Preflight resets button, input, select, optgroup and textarea in one rule.

Two differences remain. Textarea gets resize: vertical by default, and a select needs appearance-none before a custom arrow can replace the native one.

How do I hide the spinner buttons on a number input?

Add [appearance:textfield] for Firefox, plus [&::-webkit-inner-spin-button]:appearance-none and [&::-webkit-outer-spin-button]:appearance-none for Chrome and Safari.

In v4, an @utility rule can bundle the three into one reusable class.

Can a Tailwind input grow as the user types?

Yes. field-sizing-content resizes an input or textarea to fit its text, and field-sizing-fixed restores the default.

Chrome 123, Safari 26.2 and Firefox 152 all support the property, according to web.dev’s June 2026 platform update.

Is the Play CDN fine for production forms?

No. Tailwind’s installation docs describe the Play CDN as designed for development only and not intended for production.

Ship a compiled stylesheet through the Vite plugin, the PostCSS plugin or the Tailwind CLI instead.

Does React Hook Form work with Tailwind inputs?

Yes. The register function spreads onto a native input, so every Tailwind class keeps working.

Set aria-invalid from the errors object, then style the failed state with aria-[invalid=true]: classes.

How do I change the caret color in an input?

Use a caret-* utility such as caret-pink-500, which sets caret-color on the text cursor.

Custom values work too, as in caret-[#50d71e] or caret-(–my-caret-color), per the Tailwind documentation.

Keeping Tailwind Input Styles Consistent Across Forms

Consistency comes from one shared definition holding the class list, either a framework component for the field or a single @utility class, with every form calling that definition.

Put the base list and explicit colors in first, since every state variant overrides them. Focus goes next. Invalid goes last, because every field takes focus while only validated fields can fail.

The cost is visibility. Styles leave the markup, so one edit changes every form at once and a reader no longer sees a field’s classes in place.

This position was verified against the Tailwind v4.3 documentation in October 2026. A major release that changes input defaults is the trigger to review the shared definition, and with this setup that review covers one file.